Daniel Missud

A small slice of my life

Dojo TDD — Black Box vs White Box: testing behavior or implementation?

A coding dojo is a deliberate training session: a well-defined problem, explicit constraints, and a thought-provoking debriefing. This one revolves around a library system and a fundamental question: When writing tests, are we testing a behavior or an implementation? The repositories are available on GitHub: black-box And white-box.

The system — a library in hexagonal architecture

The domain is intentionally simple. Three business concepts:

  • Member — a subscriber, of a certain type CHILD, ADULT Or PREMIUM
  • Book — a book, categorized YOUTH, STANDARD Or PREMIUM
  • Loan — an existing loan, with a start date, expiry date and optional return date

The architecture follows the ports & adapters pattern: the domain exposes interfaces (MemberUseCase, BookUseCase, LoanUseCase) and output ports (MemberRepository, BookRepository, LoanRepositoryThe infrastructure provides in-memory H2 implementations. The domain does not depend on anything external.

Integration tests (LibraryIntegrationTest) already cover the basic functionalities: registering a member, adding a book to the catalog, borrowing, returning, listing active loans. These tests are successful at the start — this is the solid foundation on which the dojo will build.

The mission — to implement a policy for access to loans

THE LoanService The initial approach is naive: it borrows without any professional verification. The dojo's mission is to implement three rules in LoanAccessPolicyTest following the RED → GREEN → REFACTOR cycle:

  • Rule 1 — Access by category : YOUTH is accessible to all; STANDARD to ADULT and PREMIUM; PREMIUM to PREMIUM only.
  • Rule 2 — Quota of simultaneous borrowing : 2 for CHILD, 5 for ADULT, 10 for PREMIUM.
  • Rule 3 — Maximum duration 21 days from today, not one more.

In case of a violation, a dedicated business exception must be raised: BookAccessDeniedException, LoanQuotaExceededException, LoanDurationExceededException. Both groups receive exactly the same instructions, but with a different constraint on how to write the tests.

Black box — test the contract, ignore the inside

The black box instruction is explicit: «"You must not read any classes from the packages" application Or infrastructure. You only test via the interfaces defined in domain/port/in. »

In practice, the test setup mounts a real H2 database in memory and instantiates the services via their interfaces:

private MemberUseCase memberUseCase;
private BookUseCase   bookUseCase;
private LoanUseCase   loanUseCase;

@BeforeEach
void setUp() throws SQLException {
    DatabaseConfig.initialize();
    memberUseCase = new MemberService(new H2MemberRepository());
    bookUseCase   = new BookService(new H2BookRepository());
    loanUseCase   = new LoanService(...);
}

To test rule 1, we actually create a CHILD member and a STANDARD book in the database, then we attempt to borrow via loanUseCase.borrow() and we check for the exception. No mock, no stub: the entire system runs. The constraint is not technical, it is intellectual — we don't look at the service code to know what it does, we deduce the expected behavior from the specifications.

It's uncomfortable at first. We don't know if LoanService already implements the rule or not. We write the RED test blindly, then we run it mvn test To see. That's exactly it, TDD.

White box — test quickly, with mocks on concrete classes

The white box instruction opens everything: «"You have access to all the source code: models, services, repositories. You can mock the repositories directly and instantiate them." LoanService by hand.» She adds an educational note: «"Keep in mind that you will discover at the end of the session why too strong a coupling to concrete classes can become a problem."»

The setup is minimal thanks to Mockito:

@Mock private H2MemberRepository memberRepository;
@Mock private H2BookRepository   bookRepository;
@Mock private H2LoanRepository   loanRepository;

@BeforeEach
void setUp() {
    loanService = new LoanService(loanRepository, memberRepository, bookRepository);
}

Each test configures exactly what it needs. For the quota rule, we stubble findActiveByMemberId to return a list of N existing loans, without modifying the database:

@Test @DisplayName("A child cannot have more than 2 simultaneous loans") void shouldRejectLoanWhenChildReachedMaxQuota() { List activeLoans = List.of( new Loan(UUID.randomUUID(), memberId, UUID.randomUUID(), LocalDate.now(), LocalDate.now().plusDays(7)), new Loan(UUID.randomUUID(), memberId, UUID.randomUUID(), LocalDate.now(), LocalDate.now().plusDays(7)) ); when(memberRepository.findById(memberId)) .thenReturn(Optional.of(new Member(memberId, "Alice", MemberType.CHILD))); when(bookRepository.findById(bookId)) .thenReturn(Optional.of(new Book(bookId, "Book", "Author", BookCategory.YOUTH))); when(loanRepository.findActiveByMemberId(memberId)).thenReturn(activeLoans); assertThrows(LoanQuotaExceededException.class, () -> loanService.borrow(memberId, bookId, LocalDate.now().plusDays(7)) ); }

The tests are fast, accurate, and isolated. Each test checks exactly one thing, without noise. Borderline case coverage is easily achieved.

The TDD progression — RED, GREEN, REFACTOR

In both cases, the progression follows the same rhythm. We begin by writing a first RED test on the simplest rule—a CHILD trying to borrow a STANDARD book. The test fails because the LoanService The starting point doesn't verify anything. That's intentional.

We implement the minimum in LoanService.borrow() To perform this test: retrieve the member, retrieve the book, compare the types, and raise the exception if necessary. The test passes (GREEN). No further action is required.

Then we add the next test—an ADULT trying to purchase a PREMIUM book—and extend the rule. Then the quota rule. Then the duration. With each iteration, the existing tests act as a safety net: you can't break a rule that's already been implemented without it being immediately apparent.

The REFACTOR phase occurs naturally when conditions accumulate in borrow(). We can extract a method checkAccessPolicy(), or a dedicated class. The tests remain green: they are what define what the refactoring must preserve.

The debrief — why it really matters

This is where the dojo becomes truly valuable. We compare the two test bases in the face of a refactoring scenario: we rename H2MemberRepository in InMemoryMemberRepository, or the access policy is extracted into a dedicated domain object.

White-box tests fail immediately. They mocked H2MemberRepository by its class name — the compiler complains before even running the tests. Worse: if we refactor LoanService.borrow() to delegate the verification to a Loan Policy, The mocks no longer correspond to the new structure. Tests that previously tested the same business rule need to be rewritten.

Black box tests remain unchanged. They know neither H2MemberRepository neither LoanService. They just know that loanUseCase.borrow(childId, standardBookId, dueDate) must raise an exception. This contract is stable — it is defined by the business rule, not by the current implementation.

The pedagogical value of the white box instruction took on its full meaning at that moment: «"Too strong a coupling with concrete classes can become a problem."» — it wasn't an abstract warning, it was a prophecy.

My takeaway from this

The white box allows you to write tests faster, with more control over edge cases. It's often more comfortable, especially when starting out with TDD. But this comfort comes at a price: tests adhere to the internal structure, and every refactoring becomes a risk of unnecessary breakage.

The black box approach requires an initial effort—understanding and formulating the expected behavior without looking at the implementation. But it produces tests that last, document business rules, and unleash refactoring rather than hinder it.

The source code is on GitHub: dmissud/black-box And dmissud/white-box. The instructions are in INSTRUCTIONS.md at the root of each repo — sufficient to animate the session with any Java group.

Leave a Reply

Your email address will not be published. Required fields are marked *