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; }); 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 captor = ArgumentCaptor.captor(); verify(getMockDao(), times(1)).create(captor.capture()); final Domain domainPassedToDao = captor.getValue(); // A transient domain has no identity until the DAO
Comments
No comments yet. Start the discussion.