Career OS

Testing That Proves Something

You refactor the settle-up method, everything compiles, the app starts, and you ship it. Three weeks later a friend in some group is owed one paisa less than they should be — and nobody notices until the numbers are big enough to argue about. A green build proved the code runs. This module is about making green mean the code is right — and about the uncomfortable truth that most tests people write (and most tests AI generates) prove nothing at all.

The Goal

By the end of this module you can:

  • Place any test on the pyramid and justify why it lives there — and why most of yours live at the bottom
  • Write plain JUnit tests for LedgerService money math with constructor-injected fakes — no Spring, no database, milliseconds
  • De-magic @SpringBootTest and @WebMvcTest — say exactly what each one boots and what it costs
  • Drive a controller with MockMvc — perform, andExpect, jsonPath — without opening a port
  • Decide what NOT to test, and defend the decision in code review
  • Name tests so the suite reads like a specification of SplitEase

The Lesson

De-magic first: a test is just a method that complains

Strip everything away and a test is plain Java:

public static void main(String[] args) {
    var ledger = new LedgerService(new InMemoryFriendRepo(), new InMemoryExpenseRepo());
    // ...add a 1000-paise expense among 3 friends...
    long sum = ledger.balances().values().stream().mapToLong(Long::longValue).sum();
    if (sum != 0) throw new AssertionError("paise leaked: " + sum);
}

JUnit adds exactly two things: a runner that finds every @Test method and runs them all, and assertions (assertEquals, assertThat) that throw with a readable message when reality disagrees with expectation. That’s the whole framework. No Spring anywhere.

Notice what made that snippet possible: new LedgerService(fakeFriends, fakeExpenses). This is why module 01 banned field injection (what-spring-solves). With constructor injection, any code — including a test — can hand the service whatever dependencies it wants. With field @Autowired, the only things that can fill those fields are the Spring container (slow) or reflection hacks (gross). The testing payoff was the point all along.

The pyramid, honestly

flowchart TD
    A["SpringBootTest - one or two full paths - seconds each"] --> B["WebMvcTest slices - a handful per controller"]
    B --> C["Plain JUnit on services - dozens - this is where the value is"]
LayerWhat it proves for SplitEaseCostHow many
Unit (plain JUnit)The money math: balances, settle-up, paise remaindersMilliseconds eachDozens — the bulk of your suite
Slice (@WebMvcTest)URLs map, validation fires, error JSON has the right shape~1–2 s to build the slice, fast afterA few per controller
Integration (@SpringBootTest)The whole machine works wired together: register → login → expense → balancesMany seconds, boots everythingOne or two paths. Total.

Here’s the honest part most courses skip: the value is overwhelmingly at the bottom. The bugs that cost money in SplitEase are math bugs — a remainder dropped, a sign flipped, an empty list mishandled. Every one of those is provable in a plain JUnit test that runs in milliseconds with zero Spring. The slices and the integration test exist to prove the plumbing, and plumbing needs far fewer proofs than logic.

What the test annotations actually boot

AnnotationWhat loadsSpeedWhen to use
(none — plain JUnit)Nothing. You call new yourselfMillisecondsService and business logic. Your default.
@WebMvcTest(X.class)DispatcherServlet, that one controller, JSON converters, Bean Validation, your @RestControllerAdvice. No services, no repositories, no DB — collaborators must be provided as mocks~1–2 s onceRequest mapping, status codes, validation rules, error JSON shape
@DataJpaTestJPA, your repositories, an embedded DBSecondsHand-written @Query methods (rare)
@SpringBootTestThe entire application context — every bean, the security filter chain, the worksSlowestOne or two end-to-end proofs

De-magic: @SpringBootTest is not a special “test mode.” It runs the same startup sequence as ./mvnw spring-boot:run — component scan, auto-configuration, bean wiring — then injects beans into your test class. That’s why it’s slow: you’re paying full application boot.

One production trap worth knowing now: Spring caches the application context between test classes with identical configuration. The first @SpringBootTest class pays the boot cost; later ones reuse the context. But every different combination of mocked beans and properties forces a new context. Teams that sprinkle @SpringBootTest plus a different @MockitoBean on every test class end up with 15 contexts and a 40-minute build — and then people stop running the tests.

The star example — money math under test

The settle-up algorithm you hand-worked in the Core Java capstone is now LedgerService. These are the cases that earn their keep — table-driven, each one a behavior someone could break:

CaseInputMust hold
The 1000/31000 paise dinner among 3 friendsShares sum to exactly 1000 — payer absorbs the remainder, no paisa created or destroyed
Empty ledgerNo expensesAll balances zero, settle returns an empty list
Single friendAsha pays 500 for only herselfNet zero, no payments
The invariantAny set of expensesBalances always sum to zero

First, the fakes. LedgerService depends on repository interfaces, so a fake is just an in-memory implementation:

class InMemoryExpenseRepo implements ExpenseRepository {
    private final Map<Long, Expense> store = new HashMap<>();
    private long nextId = 1;

    @Override
    public <S extends Expense> S save(S expense) {
        // set id if your entity allows it, then:
        store.put(nextId++, expense);
        return expense;
    }

    @Override
    public List<Expense> findAll() {
        return List.copyOf(store.values());
    }

    // JpaRepository declares ~20 more methods. Let your IDE generate the
    // overrides and leave each one as: throw new UnsupportedOperationException();
}

That UnsupportedOperationException trick is honest engineering: if a test ever trips one, the service is using repository behavior you didn’t plan for — which is itself information. (Mockito’s mock(ExpenseRepository.class) plus when(...).thenReturn(...) does the same job with less typing; fakes keep the data behavior real. Both are legitimate — pick one and be consistent.)

Now the tests. Mirror InMemoryFriendRepo for friends, then:

class LedgerServiceTest {

    InMemoryFriendRepo friends = new InMemoryFriendRepo();
    InMemoryExpenseRepo expenses = new InMemoryExpenseRepo();
    LedgerService ledger = new LedgerService(friends, expenses);
    // adjust constructor and method names to what you built in module 06

    @Test
    void balances_thousandSplitThreeWays_noPaisaLost() {
        addFriends("Asha", "Rohit", "Darshan");
        ledger.addExpense(1000L, "chai", "Asha", List.of("Asha", "Rohit", "Darshan"));

        Map<String, Long> balances = ledger.balances();

        assertEquals(666L, balances.get("Asha"));    // paid 1000, share 334
        assertEquals(-333L, balances.get("Rohit"));
        assertEquals(-333L, balances.get("Darshan"));
        assertEquals(0L, balances.values().stream().mapToLong(Long::longValue).sum());
    }

    @Test
    void settle_emptyLedger_returnsNoPayments() {
        addFriends("Asha");
        assertTrue(ledger.settle().isEmpty());
    }

    @Test
    void settle_singleFriendPaysForSelf_returnsNoPayments() {
        addFriends("Asha");
        ledger.addExpense(500L, "snacks", "Asha", List.of("Asha"));
        assertTrue(ledger.settle().isEmpty());
    }
}

Look at what those assertions pin down: exact paise values, the remainder going to the payer, the sum-to-zero invariant. If anyone — you, a teammate, an AI refactor — changes the remainder handling, balances_thousandSplitThreeWays_noPaisaLost fails with the exact numbers in its message. That is a test that proves something.

MockMvc — driving the web layer without a server

@WebMvcTest gives you MockMvc: an object that builds a fake HTTP request and hands it straight to DispatcherServlet — no port, no network, but the real routing, real validation, real error handling:

mockMvc.perform(post("/api/v1/friends")
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"name\": \"Asha\"}"))
    .andExpect(status().isCreated())
    .andExpect(jsonPath("$.name").value("Asha"));

Three pieces:

  • perform(...) — builds the request (method, path, headers, body) and dispatches it through the real DispatcherServlet from module 03.
  • andExpect(...) — assertions on the response: status(), header(), content().
  • jsonPath(...) — a mini query language for the response JSON: $.name is the top-level name field, $[0].name the first array element’s name, $.message your error handler’s message field.

This layer catches the bugs unit tests are blind to: a typo in @PostMapping("/api/v1/freinds"), a deleted @Valid, a handler returning 200 where you promised 201, error JSON that changed shape and broke the frontend.

What NOT to test

Every test costs maintenance forever. Don’t pay for proofs of nothing:

  • Getters, setters, records. No logic — you’d be testing the compiler.
  • The framework. Don’t write a test proving @GetMapping routes GET requests. Spring’s own test suite — thousands of engineers deep — covers that.
  • Derived queries. findByName(String name) was generated by Spring from the method name. Testing it tests Spring plus H2, not your code. The exception: a hand-written @Query with real JPQL in it is your logic — that’s what @DataJpaTest is for.

And the AI angle, baked in rather than bolted on: AI writes plausible tests that assert nothing. Ask it to “add tests” and you’ll get suites full of assertNotNull(result), tests that mock every collaborator and then assert the mock returned what it was told to return, and tests that re-state the implementation line by line. Coverage goes up; proof doesn’t. The skill that survives is knowing which behaviors need proof — the paisa remainder, the 404 on an unknown payer, the 401 on an expired token — and that requires understanding the system, not generating around it.

Names that document behavior

The convention: method_condition_outcome.

  • createExpense_payerNotInGroup_returns404
  • settle_emptyLedger_returnsNoPayments
  • register_duplicateUsername_returns409

When balances_thousandSplitThreeWays_noPaisaLost fails in CI, the name alone tells you what broke and why it matters — before you read a single line of the test. Run the suite and the output reads like a specification of SplitEase. test1, testCreate, shouldWork tell you nothing; they’re the naming equivalent of assertNotNull.

H2 vs the truth

Your tests will run against in-memory H2 — fast, zero setup, dies with the JVM. Fine for this track. But be honest about the lie: H2 is not PostgreSQL. Dialect differences (Postgres ON CONFLICT, type quirks, identifier case rules) mean a query can pass on H2 and fail in production. The real-world answer is Testcontainers — your tests start a real PostgreSQL in Docker, run against the genuine article, and tear it down. Week 4 of the sprint plan flagged it (week 4); once Docker lands in Month 3 you’ll be equipped to use it. Until then: H2 in tests is a known, accepted shortcut — know that you’re taking it.

Check The Concept

How This Shows Up At Work

  • The classic review comment: “This test mocks every collaborator and then asserts the mock returned what it was told to return. It can never fail.” The most common feedback on junior (and AI-generated) test code — after this module, you’ll write it instead of receiving it.
  • The green-build incident: CI green, production down. A query passed on H2 and failed on PostgreSQL. The engineer who knows why the suite couldn’t catch it — and says “Testcontainers” in the postmortem — is the one trusted with the fix.
  • The 40-minute suite: someone put @SpringBootTest on everything; nobody runs the tests locally anymore; bugs ship. Knowing slices and context caching is how you fix a team-level problem, not just a file.
  • The interview pair: “Difference between @SpringBootTest and @WebMvcTest?” then the follow-up that filters candidates: “How do you test a service without starting Spring at all?” Your answer — constructor injection plus fakes — is this module in one sentence.

Build This

Where you’re starting from: after module 07, SplitEase has register/login issuing JWTs, every endpoint protected except /api/v1/auth/** and health, LedgerService with the ported balance and settle-up logic, and a global error handler returning consistent JSON. Tests so far: the generated contextLoads and nothing else. Time to fix that.

  1. Look at what you already have. Open pom.xmlspring-boot-starter-test has been there since module 02. De-magic it: that one starter bundles JUnit 5, Mockito, AssertJ, JsonPath, and MockMvc. You add nothing.

  2. Give tests their own database. Create src/test/resources/application.properties so tests never touch your real PostgreSQL:

spring.datasource.url=jdbc:h2:mem:splitease-test;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop

Make sure the H2 dependency from module 04 is still in the pom with <scope>test</scope>.

  1. The unit tests — the bulk of the value. Create src/test/java/.../LedgerServiceTest.java with the in-memory fakes and the four cases from the lesson: the 1000/3 remainder, the empty ledger, the single friend, and the sum-to-zero invariant. Type the exact-paise assertions — 666, -333, -333 — not “is not null”.

  2. One slice test. Create FriendControllerTest:

@WebMvcTest(FriendController.class)
@AutoConfigureMockMvc(addFilters = false)
class FriendControllerTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean FriendService friendService;

    @Test
    void createFriend_validName_returns201() throws Exception {
        when(friendService.create("Asha")).thenReturn(new FriendResponse(1L, "Asha"));

        mockMvc.perform(post("/api/v1/friends")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\": \"Asha\"}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.name").value("Asha"));
    }

    @Test
    void createFriend_blankName_returns400WithErrorJson() throws Exception {
        mockMvc.perform(post("/api/v1/friends")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\": \"\"}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.message").exists());
            // assert the exact field names YOUR module 06 handler returns
    }
}

Two honest notes. addFilters = false deliberately switches security off in this slice — otherwise the slice boots a default deny-everything setup and every test gets a 401; proving security is the integration test’s job, and each layer should test one thing. And @MockitoBean is the current name — older tutorials say @MockBean, deprecated since Boot 3.4, same job.

  1. One integration test. The full secured path, end to end:
@SpringBootTest
@AutoConfigureMockMvc
class SpliteaseFlowTest {

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;

    @Test
    void fullFlow_registerLoginExpense_balancesSumToZero() throws Exception {
        mockMvc.perform(post("/api/v1/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"username\": \"darshan\", \"password\": \"change-me-in-prod\"}"))
            .andExpect(status().isCreated());

        String loginBody = mockMvc.perform(post("/api/v1/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"username\": \"darshan\", \"password\": \"change-me-in-prod\"}"))
            .andExpect(status().isOk())
            .andReturn().getResponse().getContentAsString();
        String token = objectMapper.readTree(loginBody).get("token").asText();

        for (String name : List.of("Asha", "Rohit")) {
            mockMvc.perform(post("/api/v1/friends")
                    .header("Authorization", "Bearer " + token)
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"name\": \"" + name + "\"}"))
                .andExpect(status().isCreated());
        }

        mockMvc.perform(post("/api/v1/expenses")
                .header("Authorization", "Bearer " + token)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"description\": \"dinner\", \"amountPaise\": 1000, " +
                         "\"paidBy\": \"Asha\", \"sharedAmong\": [\"Asha\", \"Rohit\"]}"))
            .andExpect(status().isCreated());

        mockMvc.perform(get("/api/v1/balances")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.Asha").value(500))
            .andExpect(jsonPath("$.Rohit").value(-500));
    }
}

Adjust JSON field names to your modules 05–07 records. This single test proves the wiring: security lets registered users in, the token works, the expense lands in the database, the math comes back through JSON.

  1. Run it all:
./mvnw test

Expected: something like Tests run: 8, Failures: 0, Errors: 0 and BUILD SUCCESS. Notice the timing Maven prints — the unit tests are near-instant, the integration test dominates. That’s the pyramid as measured fact.

  1. Break it on purpose #1 — mutate the money. In the settle-up logic, sabotage one thing: flip the remainder handling, or swap a Math.min for Math.max. Run ./mvnw test. Watch balances_thousandSplitThreeWays_noPaisaLost fail with expected: 666 but was: 667 — the right test failing for the right reason. Say out loud what this proves: the test is load-bearing. A test you’ve never seen fail is a test you’re trusting on faith. (You just did manual mutation testing — tools like PIT automate exactly this.) Revert.

  2. Break it on purpose #2 — the assertion-free test. Add this and run the suite:

@Test
void settle_looksImportant() {
    ledger.settle();
}

Green. Now delete it, and say why it was worse than no test: it inflates the count, it certifies nothing, and it can never fail — a green light wired to stay green is more dangerous than no light, because people trust it. This is the exact shape of most AI-generated tests. You now recognize it on sight.

Where to Practice

ResourceWhat to do thereHow long
spring.io/guides — “Testing the Web Layer”Work through the official guide; map each test it writes to a pyramid layer45 min
docs.spring.io — Spring Boot reference, Testing chapterRead the sections on test slices and @SpringBootTest; skim the auto-configured annotations list30 min
BaeldungSearch “spring boot webmvctest” and “mockmvc jsonpath” — read the free articles alongside your own slice test30 min

Check Yourself

  1. Why can LedgerService be tested with no Spring at all — and which module 01 decision made that possible?
  2. What loads under @WebMvcTest(FriendController.class), and what is deliberately absent?
  3. Your sliced controller needs a FriendService. What do you do, and what happens if you don’t?
  4. Why is an assertion-free test worse than no test?
  5. Why shouldn’t you write a test for findByName? When does a repository method deserve a test?
  6. What exactly does the 1000/3 test protect against, in paise?
  7. What’s the honest problem with H2 in tests, and what’s the real-world answer?
  8. You mutate the settle-up logic and a test fails. What did that exercise just tell you about your suite?
Answers
  1. It takes its repositories through its constructor, so a test can call new LedgerService(fakeFriends, fakeExpenses) directly. Module 01 banned field injection precisely to keep this door open — field-injected dependencies can only be filled by the container or reflection.
  2. The web layer for that one controller: DispatcherServlet, JSON converters, Bean Validation, and your @RestControllerAdvice. Absent: services, repositories, the database — any collaborator must be supplied, usually with @MockitoBean.
  3. Declare it as a @MockitoBean and script its behavior with when(...).thenReturn(...). Without it, the slice fails to start with a missing-bean error because nothing in the slice can build the controller.
  4. It can never fail, so it certifies nothing while looking like proof — inflating the test count and the team’s false confidence. No test at least leaves the gap visible.
  5. Spring generated findByName from the method name; testing it tests Spring’s query derivation, which Spring already tests. A hand-written @Query contains your logic and earns a @DataJpaTest.
  6. Integer division dropping the remainder: 1000/3 gives 333 each, and the leftover paisa must go somewhere deliberate (the payer’s share, per the Core Java design). The test pins the exact values — 666, −333, −333 — and the sum-to-zero invariant.
  7. H2 is not PostgreSQL — dialect and type differences mean a query can pass in tests and fail in production. The real answer is Testcontainers: tests run against a genuine PostgreSQL in Docker.
  8. That the test is load-bearing — it actually guards the behavior it claims to. A suite where mutations survive (tests stay green) is a suite full of decoration; this is what mutation-testing tools automate.

Explain it out loud: Explain the test pyramid for SplitEase specifically to an empty chair: name the three layers, what each one proves, what each costs, and why dozens of tests live at the bottom but only one or two at the top. If you can’t say what the integration test proves that the slices don’t, reread the lesson.

Still Unclear?

Quiz me on choosing the right test layer. Give me 8 one-line scenarios from a
Spring Boot expense-splitting API (a rounding bug, a wrong status code, a
missing @Valid, a broken JWT filter, a custom JPQL query...) and for each,
make me pick: plain JUnit, @WebMvcTest, @DataJpaTest, or @SpringBootTest.
Challenge every wrong pick. Don't write any test code.
Explain Spring's test context caching: when two test classes share a context,
what breaks the cache (@MockitoBean combinations, @ActiveProfiles, properties),
and how a team accidentally ends up with a 40-minute build. Use a concrete
4-test-class example. No code generation — I want the mental model.
Here is one of my tests: [paste it]. Review it like a strict senior engineer:
could it ever fail? What behavior does it actually pin down? Is it testing my
logic or the framework's? Suggest what assertion would make it prove something
— but make me write it.

Why AI Can’t Do This For You

Ask AI to “write tests for my service” and you will get green — fluent, plausible, plentiful green. What you won’t get is judgment: it doesn’t know that the paisa remainder is where SplitEase’s money integrity lives, that the error JSON shape is a contract your future frontend depends on, or that the one integration path worth paying for is register-to-balances. Choosing which behaviors need proof is a map of where your system can hurt you — and only someone who understands the system has that map.

And when a test fails at 6 pm before a release, AI can’t tell you whether the test caught a real regression or the test itself was wrong all along. The engineer who has deliberately broken their own code and watched the right test fail knows the difference in seconds. That confidence — green means right, not just runs — is built exactly one way: the way you just built it.

Module done? Add it to today’s tracker

Saves your progress on this device.