background-shape
Testing Clean Architecture, Unit, Use Case, Integration
June 27, 2022 · 5 min read · by Muhammad Amal programming

TL;DR — Three layers of tests: unit (domain types, milliseconds), use case (with in-memory fakes, ~5ms each), integration (real DB / HTTP, ~100ms each). Most tests live in the middle. Integration only for adapter-specific behavior. Total suite stays under a minute even with hundreds of tests.

After six posts on patterns, the actual payoff: how Clean Architecture changes your testing. The win is real and measurable — a well-structured service has a fast, focused test suite. The trick is not over-testing each layer.

Three tiers

The pyramid for Clean Architecture services:

Tier 1 — Unit tests on domain. Test invariants, value object validation, pure business logic. Run in microseconds. Hundreds of these.

Tier 2 — Use case tests with in-memory repositories. Test orchestration. ~5ms each. Most of your tests live here.

Tier 3 — Integration tests on adapters. Test that the Postgres impl actually saves, HTTP responses are correct. ~100ms each. Tens, not hundreds.

Other tiers (E2E, smoke) exist but are out of scope for “Clean Architecture testing.”

Tier 1: domain unit tests

func TestSubscription_Cancel_WhenAlreadyCanceled_Errors(t *testing.T) {
    sub, _ := domain.NewSubscription(uuid.New(), validPlan, time.Now())
    _ = sub.Cancel(time.Now())

    err := sub.Cancel(time.Now())

    require.ErrorIs(t, err, domain.ErrAlreadyCanceled)
}

func TestPlan_New_WithEmptyID_Errors(t *testing.T) {
    _, err := domain.NewPlan("", 1000, "USD", time.Hour*24*30)
    require.ErrorIs(t, err, domain.ErrInvalidPlan)
}

No setup. No mocks. No DB. Just the type and its rules.

These tests catch logic bugs in business rules. They run thousands per second. Write them generously.

For Laravel:

public function test_cancel_when_already_canceled_throws()
{
    $sub = Subscription::start(Uuid::uuid4(), $this->validPlan(), new DateTimeImmutable());
    $sub->cancel(new DateTimeImmutable());

    $this->expectException(AlreadyCanceledException::class);
    $sub->cancel(new DateTimeImmutable());
}

Same shape. PHPUnit instead of testify. No framework needed beyond the test runner.

Tier 2: use case tests with fakes

type fakeSubRepo struct {
    items map[uuid.UUID]*domain.Subscription
}

func newFakeSubRepo() *fakeSubRepo { return &fakeSubRepo{items: map[uuid.UUID]*domain.Subscription{}} }

func (f *fakeSubRepo) Save(ctx context.Context, s *domain.Subscription) error {
    f.items[s.ID()] = s
    return nil
}
func (f *fakeSubRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Subscription, error) {
    s, ok := f.items[id]
    if !ok { return nil, domain.ErrSubscriptionNotFound }
    return s, nil
}

func TestStartSubscription_HappyPath(t *testing.T) {
    subs := newFakeSubRepo()
    plans := &fakePlanRepo{plan: validPlan}
    pub := &fakePub{}
    clock := func() time.Time { return testNow }

    uc := usecase.NewStartSubscription(subs, plans, pub, clock)

    out, err := uc.Execute(context.Background(), usecase.StartSubscriptionInput{
        CustomerID: testCustomerID,
        PlanID:     "pro_monthly",
    })

    require.NoError(t, err)
    require.Equal(t, domain.StatusActive, out.Subscription.Status())
    require.Len(t, subs.items, 1)
    require.Len(t, pub.events, 1)
}

In-memory fakes implement the same interface as the real Postgres / Kafka adapters. Tests run without a DB.

For Laravel, same pattern:

public function test_start_subscription_happy_path()
{
    $subs = new InMemorySubscriptionRepository();
    $plans = new InMemoryPlanRepository();
    $events = $this->createMock(Dispatcher::class);
    $events->expects($this->once())->method('dispatch');

    $useCase = new StartSubscription($subs, $plans, $events);

    $output = $useCase->execute(new StartSubscriptionInput(
        customerId: Uuid::uuid4(),
        planId: 'pro_monthly',
    ));

    $this->assertEquals(Status::Active, $output->subscription->status());
    $this->assertCount(1, $subs->all());
}

Use case tests cover:

  • Happy path
  • Each domain error mapped from repository or domain method
  • Edge cases (zero-plan, deleted customer, etc.)
  • Side effects (publish was called, repository was called)

You’ll write 5-10 of these per use case. They run in seconds total.

Tier 3: integration tests on adapters

Test that the Postgres repository actually saves and retrieves correctly. Test that the HTTP adapter responds with the right shape.

func TestPostgresSubscriptionRepository_SaveAndLoad(t *testing.T) {
    db := setupTestDB(t)
    defer teardownDB(db)

    repo := postgres.NewSubscriptionRepository(db)
    sub, _ := domain.NewSubscription(uuid.New(), validPlan, time.Now())

    err := repo.Save(context.Background(), sub)
    require.NoError(t, err)

    loaded, err := repo.GetByID(context.Background(), sub.ID())
    require.NoError(t, err)
    require.Equal(t, sub.ID(), loaded.ID())
    require.Equal(t, sub.Status(), loaded.Status())
}

Real Postgres. Migrations applied. Real save and retrieve. ~50ms.

For HTTP adapter:

func TestSubscriptionHandler_Create_HappyPath(t *testing.T) {
    startSub := usecase.NewStartSubscription(/* real or in-memory deps */)
    handler := httpadapter.NewSubscriptionHandler(startSub, nil)

    body := strings.NewReader(`{"customer_id":"...","plan_id":"pro_monthly"}`)
    req := httptest.NewRequest("POST", "/", body)
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    handler.Routes().ServeHTTP(rec, req)

    require.Equal(t, http.StatusCreated, rec.Code)
    var resp map[string]any
    json.Unmarshal(rec.Body.Bytes(), &resp)
    require.Equal(t, "active", resp["status"])
}

httptest runs the handler without a real listener. Real JSON encode/decode. Real router. No need for a separate test server.

Integration tests cover adapter-specific behavior: SQL is right, JSON shape is right, HTTP codes are right. NOT business logic — that’s tier 2.

What you DON’T test in each tier

Don’t test business logic at tier 3. The integration test for the Postgres repo shouldn’t assert “cancellation flips status to canceled” — that’s a domain unit test. Tier 3 just verifies “the save/load round-trip preserves status.”

Don’t test framework code. chi’s routing works; you don’t need to test it. Your handler logic, yes.

Don’t test impls of interfaces twice. If you have an in-memory fake AND a Postgres impl, both implementing the same interface, you only need to integration-test the Postgres one (and unit-test the fake is trivial).

Suite shape

A real billing service of ours has roughly:

  • 80 domain unit tests, 0.02s total
  • 120 use case tests, 0.8s total
  • 15 integration tests, 1.5s total
  • Total: ~2.3s for the whole suite

CI runs the suite in under 5 seconds. Fast enough to run on every save in dev.

The shape only works if you commit to keeping integration tests narrow. Once integration tests creep into testing business logic, the suite slows down without proportional confidence gain.

Test fixtures

Create helpers for common setup:

func validPlan() domain.Plan {
    p, _ := domain.NewPlan("pro_monthly", 1000, "USD", time.Hour*24*30)
    return p
}

func validSubscription(t *testing.T) *domain.Subscription {
    s, err := domain.NewSubscription(uuid.New(), validPlan(), testNow)
    require.NoError(t, err)
    return s
}

Tests stay focused on what they’re proving, not setup boilerplate.

Common Pitfalls

Real DB for use case tests. Slow. Use in-memory. Real DB only for adapter integration tests.

Mocking domain methods. Don’t. Domain is small and pure; use real instances.

Over-mocking. Mock the repository; not the entity inside it.

Asserting on private fields. Use exposed methods. If you need access to internals, your design is wrong.

Snapshot tests for everything. Brittle. Use them sparingly for complex JSON shapes.

Skipping use case tests because “the integration tests cover it.” Integration tests are slow and broad. Use case tests are fast and focused. You need both for different reasons.

Wrapping Up

Three tiers, most tests in the middle, integration narrow. The result: thousands of assertions running in seconds. The architecture investment pays off here. Wednesday: June retro — what worked, what didn’t, where Clean Architecture earned its keep.