PHPUnit Mocking Made You Lazy. Go Will Force You to Design

php dev.to

You write a PHPUnit test. The class under test depends on UserService. You type $this->createMock(UserService::class). Green. Next test.

You move to Go. You try the same move. The compiler refuses. You cannot mock a *UserService. There is no subclass to override, no proxy to wrap, no magic to bend. The only path forward is to extract an interface, change the caller to accept that interface, and pass a fake.

That feels like extra work. It is not. That is the design step PHP let you skip.

The PHPUnit move that hides the seam

Here is the kind of test that ships every day in PHP shops. A controller calls a service. The service hits the database. The test mocks the service.

final class CheckoutService
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly PaymentGateway $payments,
    ) {}

    public function charge(int $userId, int $amountCents): Receipt
    {
        $user = $this->users->find($userId);
        if ($user === null) {
            throw new UserNotFound($userId);
        }
        return $this->payments->charge($user, $amountCents);
    }
}
Enter fullscreen mode Exit fullscreen mode

The PHPUnit test:

public function test_charge_returns_receipt(): void
{
    $users = $this->createMock(UserRepository::class);
    $users->method('find')
          ->with(42)
          ->willReturn(new User(42, 'a@b.test'));

    $payments = $this->createMock(PaymentGateway::class);
    $payments->method('charge')->willReturn(new Receipt('r-1'));

    $svc = new CheckoutService($users, $payments);
    $receipt = $svc->charge(42, 1000);

    self::assertSame('r-1', $receipt->id);
}
Enter fullscreen mode Exit fullscreen mode

This works whether UserRepository is an interface, an abstract class, or a concrete class with a live PDO connection inside it. PHPUnit's createMock reflects over the type and synthesises a subclass at runtime. Mockery covers more cases: it will mock concrete classes, partial objects, and (through proxied partials) methods marked final. The Mockery docs are explicit that final classes need a Proxy workaround, but the workaround exists, so the affordance is there if a codebase chooses to lean on it. (See the Mockery docs on final classes and methods.)

Two things happen when the framework can mock anything.

First, you stop noticing whether UserRepository is an interface or a class. The test passes either way. The dependency direction in your code is whatever the constructor happens to do that day.

Second, you stop noticing how big the surface area is. createMock(UserRepository::class) will happily stub a 30-method God repository. The test does not punish you for the breadth. The production code keeps growing.

That is the architectural debt. The seam is implicit. The framework manufactures one for you on demand. Your code never had to have one.

The same code in Go, and what the compiler insists on

Take the same shape in Go. Naive port:

type CheckoutService struct {
    users    *UserRepository
    payments *PaymentGateway
}

func (s *CheckoutService) Charge(
    ctx context.Context, userID int, amountCents int,
) (Receipt, error) {
    u, err := s.users.Find(ctx, userID)
    if err != nil {
        return Receipt{}, err
    }
    return s.payments.Charge(ctx, u, amountCents)
}
Enter fullscreen mode Exit fullscreen mode

Now try to write a test. You want to substitute *UserRepository with a fake that returns a canned user. You cannot. *UserRepository is a concrete struct. Go has no subclass mechanism for structs. There is no createMock(UserRepository::class) equivalent because there is no class hierarchy to lean on. gomock will not help you here either: gomock generates implementations of interfaces, not stand-ins for structs. (See the go.uber.org/mock README, the maintained fork of the original golang/mock.)

The compiler is telling you something. CheckoutService has hard-coded its dependency. There is no seam. To get one, you have to introduce it explicitly:

type UserFinder interface {
    Find(ctx context.Context, id int) (User, error)
}

type PaymentCharger interface {
    Charge(ctx context.Context, u User, cents int) (Receipt, error)
}

type CheckoutService struct {
    users    UserFinder
    payments PaymentCharger
}
Enter fullscreen mode Exit fullscreen mode

*UserRepository (the concrete struct, with its real database handle) satisfies UserFinder structurally. Go's interfaces are satisfied implicitly, no implements keyword required. The test can pass any type that has a matching Find method:

type stubUsers struct{ user User }

func (s stubUsers) Find(_ context.Context, _ int) (User, error) {
    return s.user, nil
}
Enter fullscreen mode Exit fullscreen mode

That is the entire test double. Six lines. No code generation. No reflection. No DSL.

What looks like extra ceremony, declaring UserFinder, is the architectural decision PHP let you defer. The interface is your port. The struct is your adapter. The fake is another adapter that happens to live in _test.go.

Interface at the consumer, not at the producer

The Go convention is the part that surprises PHP developers most. You do not put UserFinder next to UserRepository. You put it next to CheckoutService, the code that uses it. The producer returns the concrete struct. The consumer declares what slice of behaviour it needs.

This is the proverb Jack Lindamood popularised in 2016 and that has since become Go canon: accept interfaces, return structs. He also warned about the pre-emptive interface anti-pattern, declaring an interface beside every struct "in case someone needs to mock it later." Do not do that. Add the interface when a real consumer asks for one. Until then, the struct is the contract.

Concretely:

// package userdb: the producer. Returns a struct.

type Repository struct {
    db *sql.DB
}

func New(db *sql.DB) *Repository { return &Repository{db: db} }

func (r *Repository) Find(ctx context.Context, id int) (User, error) {
    // ...real SQL...
}
Enter fullscreen mode Exit fullscreen mode
// package checkout: the consumer. Declares the interface it needs.

type UserFinder interface {
    Find(ctx context.Context, id int) (User, error)
}

type Service struct {
    users    UserFinder
    payments PaymentCharger
}
Enter fullscreen mode Exit fullscreen mode

userdb.Repository knows nothing about checkout.UserFinder. The consumer's interface is shaped by the consumer's needs: one method, two methods, whatever it actually calls. If a second consumer later needs three different methods from the same repository, it declares its own interface. The struct grows zero new types.

This is the part that becomes obvious once you see it: interface size is bounded by usage, not by the producing type's surface area. A 30-method PHP UserRepository typically had 30 methods because the framework would mock all of them anyway, and nobody pushed back. In Go, the consumer's interface is forced to stay small because the consumer literally writes it. If your test fake has 30 stub methods, the production caller needed 30 methods, which is its own warning sign.

The same seam, three doubles, three styles

Once the interface exists, you have three reasonable options for the test double. None of them require a framework, but a framework is available if you want one.

1. Hand-rolled fake

The default, and the one I reach for first:

type stubUsers struct {
    user User
    err  error
}

func (s stubUsers) Find(
    _ context.Context, _ int,
) (User, error) {
    return s.user, s.err
}

func TestCharge_UserNotFound(t *testing.T) {
    svc := checkout.NewService(
        stubUsers{err: ErrNotFound},
        stubPayments{},
    )
    _, err := svc.Charge(ctx, 42, 1000)
    if !errors.Is(err, ErrNotFound) {
        t.Fatalf("got %v, want ErrNotFound", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Eight lines for the fake. Zero dependencies. The test reads top-to-bottom. Redowan Delowar argued in Your Go tests probably do not need a mocking library that this is the right default for most Go code, and the Learn Go With Tests chapter on working without mocks makes the same case. They are right for the same reason: when the interface is small (which the consumer-side rule enforces), a hand-rolled fake costs less than the import.

2. testify/mock

If you want call-recording without code generation, stretchr/testify/mock gives you a runtime API:

type usersMock struct{ mock.Mock }

func (m *usersMock) Find(
    ctx context.Context, id int,
) (User, error) {
    args := m.Called(ctx, id)
    return args.Get(0).(User), args.Error(1)
}

func TestCharge_CallsFindOnce(t *testing.T) {
    users := &usersMock{}
    users.On("Find", mock.Anything, 42).
        Return(User{ID: 42}, nil).Once()

    svc := checkout.NewService(users, stubPayments{})
    _, _ = svc.Charge(context.Background(), 42, 1000)

    users.AssertExpectations(t)
}
Enter fullscreen mode Exit fullscreen mode

The boilerplate per method is similar to a hand-rolled fake, but you get On(...), Return(...), Once(), and AssertExpectations(t) for free. Useful when the test is specifically about interaction: was this called, with these arguments, exactly once.

3. gomock (now go.uber.org/mock)

For wide interfaces or strict interaction tests, mockgen generates the double from the interface declaration:

//go:generate mockgen -source=checkout.go -destination=mocks/checkout_mock.go

func TestCharge_GomockExample(t *testing.T) {
    ctrl := gomock.NewController(t)
    users := mocks.NewMockUserFinder(ctrl)

    users.EXPECT().
        Find(gomock.Any(), 42).
        Return(User{ID: 42}, nil)

    svc := checkout.NewService(users, fakePayments{})
    _, err := svc.Charge(context.Background(), 42, 1000)
    if err != nil {
        t.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Worth it when the interface is wide or you want strict ordering with gomock.InOrder. Costs a code-generation step in your build, an extra package, and a DSL the next maintainer has to learn. The Leapcell write-up Mastering Mocking in Go: gomock vs Interface-Based Fakes lays out the trade-off cleanly: gomock pays off when interaction verification is the point of the test; hand-rolled fakes win when behaviour is the point.

The interesting part is what these styles share. They all consume the same interface. The architectural decision (extract a port, accept it at the consumer) is what made testing possible at all. Whichever double style you pick is a taste call after that.

What changes about your design when this is the rule

Three habits flip when the framework can no longer paper over a missing seam.

Constructor parameters become a design statement. In PHP, new CheckoutService($users, $payments) could take concrete classes and the test would still pass. The constructor told you nothing about what the service depended on conceptually. In Go, when the parameters are interfaces and the interfaces are declared next to the consumer, the constructor signature is the dependency contract. You read NewService(users UserFinder, payments PaymentCharger) and you know exactly what the service needs, with no production code reading required.

Interface size shrinks under pressure. Because each consumer declares its own interface, "I need one method" produces a one-method interface. Go's standard library is full of these: io.Reader, io.Writer, fmt.Stringer, error. The pattern is not academic. It is what idiomatic Go code looks like in practice. A PHP repository with find, findAll, findBy, findOneBy, count, save, delete, flush, clear, refresh, merge, detach... has nothing forcing it to split. A Go consumer that calls only Find declares only Find.

You stop mocking what you do not own. PHPUnit and Mockery happily mock framework classes, ORM entity managers, third-party SDK clients. The test passes; the production code is still tightly coupled to a vendor type. In Go, you cannot mock the vendor's struct. So you wrap it. You declare a small interface in your code that captures the slice of the SDK you actually use, and you write a thin adapter that satisfies it using the real client. That adapter is the only place in your codebase that knows about the vendor. The wrap-and-adapt habit falls out of the constraint, not out of discipline.

The "but PHP can do this too" objection

Yes, PHP can do this too. You can program to interfaces in PHP. You can keep your repositories small. You can put UserFinder next to CheckoutService and inject it. PSR-style codebases do exactly that. The language has no objection.

The point is not that PHP makes good design impossible. The point is that PHP's tooling makes bad design invisible. createMock on a concrete class works. Mockery::mock('overload:Foo') rewrites your final class. The test suite stays green while the production code drifts into a new graph that nobody can untangle without a refactor sprint.

Go takes that affordance away. The compiler will not let you mock a struct. The standard test runner has no DSL. The first time you try to write a test for a service that does not accept an interface, you have to stop and design. The constraint is doing work the language is doing on your behalf, every single test, forever.

This is the same reason gophers tend to like Go's lack of inheritance, lack of generics for years, lack of exceptions. Each missing feature is a missing escape hatch. You have to solve the underlying problem instead of routing around it.

What this means if you are coming from PHP

A few concrete moves for the first month in Go.

Stop reaching for a mocking library on day one. Write a struct, give it the methods the consumer calls, return canned values. If the test feels painful to write, the interface is wrong (too wide, too vague), not the tooling. Fix the interface; the fake follows.

Define interfaces where you need them, not where the struct lives. When you find yourself writing package userdb and adding a UserRepository interface in the same file as *Repository, stop. The interface belongs in the package that calls Find. Move it there. The struct stays a struct.

Treat every external dependency as a candidate for a thin local interface. ORM, SDK, queue client, anything you did not write. Not because you might swap implementations. Because the test for the code that uses it should not have to spin up the dependency. The interface is the seam. The seam is the testability.

The PHP habit was: write code, then figure out how to test it, then ask the framework to help. The Go habit is: design the seam first, then writing the test is the trivial part. The compiler turns the second habit into the only one available.

So the next test you write in Go, watch what the compiler asks for. Where it forces you to declare an interface, you are looking at a port that PHP would have let you skip. Name it well. Keep it small. Then write the fake and move on.


If you want the long version of how this plays out across a whole codebase, ports, adapters, the dependency rule, why the database adapter implements an interface declared in the domain package, and what your test pyramid looks like when the architecture is doing the work, that is the spine of Hexagonal Architecture in Go.

The book picks up where this post stops. Same idea, longer arc: how the seam at the test boundary becomes the seam at the package boundary, and how that shapes everything from your domain types to your cmd/ layout.

Source: dev.to

arrow_back Back to Tutorials