DEV Community

Cover image for Testing the Service Layer - Part 1: What the Generic Suite Owes Every Service (Chapter 10)
Kamen
Kamen

Posted on Originally published at kamenivanov.substack.com

Testing the Service Layer - Part 1: What the Generic Suite Owes Every Service (Chapter 10)

In the preceding chapters, I built a clean architecture with a distinct demarcation line between the domain model, infrastructure, and web layers. The services orchestrate business logic without any awareness of Hibernate, SQL, or HTTP protocols. I apply Spring’s @Transactional declaratively solely at the service level as the transaction boundary of the Use Case, while keeping the services themselves completely clean.

Now I collect the biggest dividend of that discipline: the ability to test the entire business logic in milliseconds, with zero dependencies on the Spring context and zero real databases.

Here is a fixture I'd left half-finished.

when(getMockDao().create(any())).thenAnswer(invocation -> {
    final Domain domain = invocation.getArgument(0);
//  domain.setId(entityId);
    domain.setCreatedById(requesterId);
    savedEntity.set(domain);
    return domain;
});
Enter fullscreen mode Exit fullscreen mode

That commented-out line had been sitting there since the first draft of this fixture, a placeholder from when I was sketching out what a persisted entity should look like, meant to be finished once I got back to it. I didn't get back to it. The test above it kept passing anyway, for reasons I hadn't stopped to check, and a commented-out line that nobody's chasing has a way of looking like it isn't hiding anything. It was, once I went to actually uncomment it. Product extends a base class where id is declared final, assigned once in the constructor, never touched again for the life of the object. There is no setter to call - the line wasn't just unfinished, it was never going to compile. Which meant the fixture had never actually simulated what it claimed to: a DAO assigning an identity on persistence. It had been silently omitting that step since the day I wrote it, and the test suite built on top of it - every update, delete, and loadById test that depends on this same fixture for a "persisted" entity had never noticed, because none of those tests were checking whether the id it produced meant anything. They were checking whether a value came back, and a value always did.

Once I saw it, the question stopped being "why is this line wrong" and became "why does this class refuse to let it be right." A mutable id would mean an object could change what it is mid-lifecycle - that a Product persisted under one identity could quietly become a different Product without anyone reassigning the reference. The equality contract further up the hierarchy is built entirely on that id. If it moved, two variables holding what used to be the same entity could silently stop agreeing on whether they still were. Making id final wasn't an oversight that happened to inconvenience a test author. It was a decision that a domain object's identity is not something even its own author gets to revise after the fact and a mock that pretends otherwise isn't a shortcut, it's a small act of denying that decision ever happened. Which means the actual bug wasn't the missing setter. The actual bug was a fixture that had been left to imply persistence without ever actually simulating it. A real DAO doesn't mutate the transient instance you hand it. It takes what you give it - no identity yet, nothing persisted and it hands back something new, same data, an id that didn't exist a moment ago. The mock's job was never to make the assertion pass. It was to tell the same story a real database would tell, minus the SQL.

This is the shape most of the mistakes in this chapter turned out to have. Not a typo, not a missed edge case in the everyday sense, but a test quietly rewriting the contract of the thing it was supposed to be checking, because rewriting the contract was easier than satisfying it. AbstractCrudServiceTestCase - the generic suite that every CRUD service in this codebase is meant to inherit for free, three abstract hooks and nothing else to implement, had this problem baked into its shared fixture, which meant the problem wasn't confined to one test. It was inherited by every service that extended the base class, silently, the way a real trait gets inherited: without anyone deciding to pass it on. That word - inherited, is worth sitting with the rest of this chapter, because it cuts both ways, and the good half of it is the actual point. The same mechanism that let one broken fixture assumption propagate to every concrete service is the mechanism that, once fixed, lets every concrete service pick up the correct behavior without anyone touching them again. ProductsServiceTestCase and CategoriesServiceTestCase didn't need to relearn what ownership-stamping means, or what an idempotent delete looks like, or how authorization should fail. They inherited it - the way an organism inherits a nervous system rather than growing one from scratch each generation. And they only had to express what was actually theirs: how a Product maps its own fields, what it means for a Product specifically to change status, what a Category does differently. Software that evolves rather than gets rewritten looks like this from the inside. Not code that never changes, but code where the right thing changes and nothing else has to.


Why mock the DAO instead of an in-memory database

Before any of that inheritance can happen, there's a decision underneath it that's easy to walk past: why mock the DAO at all, instead of pointing every test at an in-memory database and calling it a day. I'd spent years testing the persistence layer that way - an H2 instance, Flyway migrations running against it, real SQL executing against a real schema and it worked, in the sense that it caught real bugs. But it was testing a different layer than the one I needed to test here, and conflating the two is exactly the kind of unexamined habit that produces slow, ambiguous test suites without anyone deciding that's what they wanted.

An in-memory database test of ProductsServiceImpl.create() verifies two things at once, bundled together whether you like it or not: that the service's own logic is correct, and that Hibernate's mapping, the schema, and the query it generates are all correct. When that test fails, you don't know which of those two failed without opening the stack trace and reading past the assertion into the actual exception. A ConstraintViolationException on a NOT NULL column tells you the schema and the entity disagree about nullability - a real, valuable thing to know but it tells you nothing about whether authorize() correctly rejects a non-owner, which is a business rule with no SQL in it at all. Persistence-layer testing, which I covered back in Chapter 7, exists precisely to isolate that first category of failure. Business-logic testing needs to isolate the second, which means the persistence layer has to be prevented from participating in the test at all. A Dao mock doesn't approximate a database, it refuses to be one. productDao.create(any()) returns exactly what the test tells it to return, nothing more, and if ProductsServiceImpl.create() is wrong, that wrongness has nowhere to hide behind an ORM quirk. The cost of that isolation is that a mocked-DAO test suite can be lying to you in a way an in-memory database test can't. Mockito doesn't know what ProductDao.create() is supposed to do, it only knows what you told it to do when you wrote when(...).thenAnswer(...). If that stub embeds an assumption that doesn't match how the real ProductDaoImpl actually behaves, the test can pass forever while the production code silently diverges from the fixture's model of it. That's not a hypothetical risk, it's the exact failure mode the half-finished create() fixture represented, just at the interface boundary instead of inside a single test. Coverage percentage cannot see this category of bug, because coverage measures whether a line executed, not whether the value flowing through that line at test time was honest. A mocked-DAO suite earns its speed and its isolation by taking on a permanent, structural obligation: every stub has to be checked, by a human, against what the real implementation actually contracts to do - the DAO's Javadoc, its own persistence-layer tests from Chapter 7, its behavior under the constraints the database schema actually enforces. Chapter 7 and this chapter aren't separate concerns that happen to share a domain object, they're two halves of a coverage claim that's only true if both halves hold, and neither replaces the other's job.


What the abstract suite actually asks of a new service

The whole promise of the base class is that a new CRUD service - some future OrdersServiceImpl, some future WarehousesServiceImpl - gets four operations, fully tested, in exchange for implementing three methods: a create transformer, an update transformer, and an authorization rule. Everything else is inherited, unquestioned, from AbstractCrudService and its matching AbstractCrudServiceTestCase. It's worth walking through why each of those four operations is shaped the way it is, because none of the shapes were arbitrary, and a few of them only look obvious in hindsight. create() has exactly one guard - a null requesterId throws before anything else runs, and then it does something that's easy to read past: it stamps createdById and updatedById onto the domain object itself, before the DAO ever sees it. The test that proves this uses an ArgumentCaptor rather than inspecting the method's return value, and the distinction matters more than it looks like it should.

@Test
void create_WhenRequesterIdIsPresent_ShouldStampOwnershipBeforePersisting() {
    final NewDomain newDomain = createValidNewDomain();
    final UUID assignedId = UUID.randomUUID();

    when(getMockDao().create(any())).thenAnswer(invocation -> withId(invocation.getArgument(0), assignedId));

    final Domain result = getService().create(newDomain, requesterId);

    final ArgumentCaptor<Domain> captor = ArgumentCaptor.captor();
    verify(getMockDao(), times(1)).create(captor.capture());
    final Domain domainPassedToDao = captor.getValue();

    // A transient domain has no identity until the DAO assigns one - the service must never invent an id itself.
    assertNull(domainPassedToDao.getId());
    assertEquals(requesterId, domainPassedToDao.getCreatedById());
    assertEquals(requesterId, domainPassedToDao.getUpdatedById());
    assertEquals(assignedId, result.getId());
    assertEquals(requesterId, result.getCreatedById());
}
Enter fullscreen mode Exit fullscreen mode

The test needs to distinguish two moments in time that a single returned object collapses into one: what the service handed the DAO before persistence, and what the DAO handed back after. If I only inspected result - the return value of create(), I'd be looking at the post-persistence object, the one withId() constructed, and I'd have no way of proving that createdById was already set on the object before it reached the DAO rather than after. That distinction sounds pedantic until you imagine the bug it protects against: a future version of create() that persists the domain object first and only stamps ownership afterward, in a follow-up dao.update() call. That version would produce an identical-looking result. It would also mean a brief window during which an unowned entity exists in the database, which is precisely the kind of window a security review should be able to trust the test suite to have already ruled out. ArgumentCaptor.capture() freezes the argument at the moment of the call, not at the moment of the test's assertion, which is the only way to make that distinction visible.

withId() itself exists because of the same immutability the opening story ran into. There is no setId() anywhere in the hierarchy, by design, so a mock that simulates persistence has to construct a genuinely new instance carrying the assigned identity rather than mutate the one it was handed. Each concrete test class implements it once, against its own domain object's actual constructors:

@Override
protected Product withId(Product transientProduct, UUID id) {
    return new Product(
            id,
            transientProduct.getCreatedById(),
            transientProduct.getUpdatedById(),
            transientProduct.getName(),
            transientProduct.getSku(),
            transientProduct.getPrice(),
            transientProduct.getSpecification()
    );
}
Enter fullscreen mode Exit fullscreen mode

update() carries a signature decision worth naming directly: authorize(Domain domain, UUID requesterId) takes the full, already-loaded entity, never just its id. It would have been the more obvious signature to reach for, and it would have quietly forced every implementation to reload the entity a second time inside authorize() itself, since ownership checks need to inspect getCreatedById(), and an id alone tells you nothing about who created anything. Passing the already-loaded domain object means the one dao.loadById() call inside update(), loadById(), and delete() does double duty: it fetches the entity for the operation, and it hands that same instance to the authorization check without a second round trip. In a mocked-DAO test, this shows up as a single verify(getMockDao(), times(1)).loadById(...) and the fact that it's times(1) and not times(2) is itself asserting something about the architecture, not just about the test. If a future refactor accidentally reintroduced a second load inside authorize(), this test would catch it immediately, because the interaction count would change even though every return value would still look correct.

The update test that checks ownership survives correctly is doing a similarly specific job, and it's worth naming exactly what regression it exists to catch, because on the surface it looks like it's testing something that can't fail:

@Test
void update_WhenRequesterIsOwner_ShouldPersistWithUpdatedByChangedAndCreatedByPreserved() {
    final Domain existingDomain = persistedEntity(requesterId);
    final UpdateDomain updateDomain = createUpdateDomain();

    when(getMockDao().loadById(existingDomain.getId())).thenReturn(existingDomain);
    when(getMockDao().update(any())).thenAnswer(invocation -> invocation.getArgument(0));

    final Domain result = getService().update(existingDomain.getId(), updateDomain, requesterId);

    final ArgumentCaptor<Domain> captor = ArgumentCaptor.captor();
    verify(getMockDao(), times(1)).update(captor.capture());
    final Domain persisted = captor.getValue();

    assertEquals(existingDomain.getId(), persisted.getId());
    assertEquals(requesterId, persisted.getCreatedById());
    assertEquals(requesterId, persisted.getUpdatedById());
    assertEquals(existingDomain.getId(), result.getId());
}
Enter fullscreen mode Exit fullscreen mode

getUpdateTransformer().copyToOutput(updateDomain, domain) is supposed to copy only the fields the update DTO actually carries - name, SKU, price, dimensions onto the loaded domain object. Nothing about UpdateProduct or UpdateCategory mentions createdById. But "the DTO doesn't carry the field" and "the transformer definitely won't touch the field" are two different guarantees, and only one of them is enforced by the type system. The shared transformer base class both UpdateProductTransformer and UpdateCategoryTransformer extend is exactly the kind of shared code where a well-intentioned future change could add a line that resets audit fields as a default, on the reasoning that every update should reset something. If that line ever landed, every existing test that only asserts on name, SKU, and price would keep passing, because none of them look at createdById at all. The test doesn't exist because I distrust today's transformer. It exists because the transformer is shared ancestry, and shared ancestry is exactly where a mistake gets inherited by every subclass at once - the same lesson the opening story already taught the hard way.

loadById() and delete() follow the same authorization pattern as update(), with one deliberate asymmetry worth calling out: delete() is idempotent by design, established back in Chapter 9 on RFC 7231 grounds, and the test proving it looks almost suspiciously simple:

@Test
void delete_WhenEntityDoesNotExist_ShouldBeANoOp() {
    final UUID randomId = UUID.randomUUID();
    when(getMockDao().loadById(randomId)).thenReturn(null);

    assertDoesNotThrow(() -> getService().delete(randomId, requesterId));

    verify(getMockDao(), times(1)).loadById(randomId);
    verify(getMockDao(), never()).delete(any());
}
Enter fullscreen mode Exit fullscreen mode

There's no exception to catch, no return value to inspect, the entire assertion is behavioral: the DAO gets asked to look, and is never asked to delete something that was never there. It's a small test, and it's easy to underrate exactly because it's small. A previous version of this same test verified dao.create() was never called instead of dao.update() - the wrong method entirely, copy-pasted down from a sibling test and never corrected. It would have passed forever regardless of what delete() actually did, because nothing it checked had anything to do with deletion. The fix wasn't clever, it was reading the test's own name against its own assertions and noticing they'd stopped agreeing with each other.


BigDecimal, and the failure that only shows up later

One more small decision belongs in this section, not because it's exotic, but because it's the kind of mistake that's genuinely invisible until it produces a wrong invoice in production. BigDecimal.equals() considers scale part of the value being compared - new BigDecimal("239.99").equals(new BigDecimal("239.990")) returns false, despite both representing the identical monetary amount. A field-mapping test asserting assertEquals(newDto.getPrice(), result.getPrice()) would be correct today, while the DTO and the domain object happen to construct their BigDecimal values with matching scale, and would become a source of mysterious, intermittent test failures the moment either side's construction path changed - a different BigDecimal.valueOf(...) overload, a database column that returns a different scale than the one supplied. Every price assertion in this suite uses compareTo() == 0 instead, because that's the only question that actually matters for money: are these the same amount, not are these represented identically.

assertEquals(0, newDto.getPrice().compareTo(result.getPrice()));
Enter fullscreen mode Exit fullscreen mode

Getting this wrong in a chapter about testing discipline would have been its own small failure of the thing the chapter argues for.


What's inherited, and what isn't yet

AbstractCrudServiceTestCase now does exactly what it was supposed to: every guard, every ownership rule, every authorization path for create(), update(), loadById(), and delete() is written once, and any CRUD service that extends AbstractCrudService picks all of it up without writing a line. ProductsServiceTestCase and CategoriesServiceTestCase prove it by how little they have to add on top - field mapping through their own transformer, and nothing else that this generic contract already covers.

But "nothing else that this generic contract covers" is doing some quiet work in that sentence, because there's a whole method - changeStatus() sitting on both concrete services that the abstract suite never touches at all, and it's worth asking why before assuming it's an oversight.

Part 2 picks up exactly there: why changeStatus() was deliberately left out of the shared ancestor, a coverage gap that survived hiding inside a fully-executed branch, and one more pass through the test suite itself to find a redundancy that had no business surviving this long.


Codebase & Architecture Blueprint

The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 10, use the following link:

Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.

◀️ Read Chapter 9: The Core Business Logic Module
▶️ Read Chapter 10 - Part 2: Testing the Service Layer: Where the Shared Ancestor Ends (Coming soon)


Join the Masterclass Journey

This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, subscribe to my Substack Newsletter here!


Recommended Resources for Java & Spring Boot Engineers

If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot & Architecture skills, check out these highly-rated resources from the Javarevisited publication (Use promo code friends20 for an exclusive 20% discount automatically applied at checkout):

Top comments (0)