RizTech Academy logo
RizTech Academy
Build Tools and TestingLesson 3 of 425 min

Good assertions and test structure

You can now write a test. This lesson is about writing ones worth keeping.

The distinction matters because a test suite is a liability as well as an asset. Every test is code somebody maintains. A suite that breaks whenever you rename a variable trains the team to ignore red builds, and a team that ignores red builds has no tests at all, however many files say otherwise.

Arrange, act, assert

One shape, and it makes a test readable in five seconds:

@Test
void countsOnlyTheRequestedMonth() {
    // arrange — set up the world
    List<Delivery> deliveries = List.of(
            new Delivery(LocalDate.of(2026, 9, 1), "Priya", 2),
            new Delivery(LocalDate.of(2026, 8, 31), "Priya", 4));

    // act — one call, the thing under test
    Map<String, Integer> counts = billing.tiffinsPerCustomer(deliveries, YearMonth.of(2026, 9));

    // assert — what should be true
    assertEquals(Map.of("Priya", 2), counts);
}

Do not write the comments. Do keep the three sections, separated by blank lines.

One act per test. Two calls to the thing under test means two tests, or a test that will fail for two different reasons and force the reader to work out which.

Name the behaviour, not the method

void testBillPaise()                                   // says nothing
void billPaiseTest2()                                  // worse
void billsAtThePlanPrice()                             // good
void namesTheCustomerWhenThereIsNoSuchSubscriber()     // good
void picksTheBusiestDayByTiffinsNotByRowCount()        // best

That last one documents a decision. Somebody reading the test list learns that "busiest" means tiffins rather than number of deliveries, without opening the implementation.

A failing test's name should tell you what broke before you read anything else. testReport failing tells you nothing; reportTotalsMatchTheIndividualBills failing tells you exactly what is now untrue.

@DisplayName is for the rest of the sentence when the method name would get silly:

@Test
@DisplayName("puts the sign before the currency, not between the halves")
void formatsNegatives() {
    assertEquals("-Rs 12.50", Money.format(-1250));
}

Assert on behaviour, not implementation

The test for a good test: would it survive a rewrite that kept the behaviour?

// brittle — asserts on the exact internal layout
assertEquals("Tiffin bill - 2026-09\n=====================\nCustomer  ...", report);

// robust — asserts on the thing that matters
assertTrue(report.contains(Money.format(expected)), report);

Change a column width and the first test fails while nothing is broken. That is the pattern that trains people to ignore failures.

The strongest version of this is asserting a relationship rather than a literal:

@Test
void reportTotalsMatchTheIndividualBills() {
    Map<String, Long> bills = billing.billsFor(DELIVERIES, YearMonth.of(2026, 9));
    long expected = bills.values().stream().mapToLong(Long::longValue).sum();

    String report = billing.renderReport(DELIVERIES, YearMonth.of(2026, 9));

    assertTrue(report.contains(Money.format(expected)),
            "report should contain the total " + Money.format(expected) + "\n" + report);
}

That test does not care what the prices are. It asserts that the printed total equals the sum of the parts — which is the actual requirement, and which stays true when prices change.

Note the failure message includes the whole report. When an assertion is not self-explanatory, pass a message, and include enough context to diagnose the failure without re-running anything.

What to test

In priority order.

1. The rules of your domain. Validation, calculations, anything with a "must". tiffins between 0 and 4, pincodes of six digits, the bill being the count times the plan price. These are the tests that earn their keep for years.

2. The edges. Zero, one, many, empty, maximum, minimum, just over. Rs 0.05 catches a missing %02d; an empty month catches a divide-by-zero or a null.

@Test
void hasNoBusiestDayWhenThereAreNoDeliveries() {
    assertTrue(billing.busiestDay(List.of()).isEmpty());
}

3. Bugs you have fixed. Every bug is a missing test. Write it, watch it fail, then fix — that way you know the test would have caught it.

4. Round trips. Save then load, serialise then deserialise, format then parse. These catch two halves of a system disagreeing, which testing either half alone never finds.

5. Error paths. That the right exception is thrown, with the right message.

What not to test

  • Getters, setters and records. Testing that new Subscriber("Priya", …).name() returns "Priya" tests the compiler.
  • The framework. Jackson's serialisation, Maven, the standard library. They have their own tests.
  • Private methods. Test them through the public method that uses them. If that feels impossible, the private method probably wants to be its own class.
  • Exact log output, unless the log is the product.
  • Everything, in pursuit of a coverage number. 100% coverage with weak assertions is worse than 60% with strong ones, because it looks finished.

Coverage tells you what is definitely untested. It does not tell you what is tested well. A test that calls a method and asserts nothing counts as coverage.

Keeping tests fast

A suite that takes ten minutes gets run once a day. The one above runs in about a second:

[INFO] Tests run: 36, Failures: 0, Errors: 0, Skipped: 0
[INFO] Total time:  1.340 s
  • No sleeps. Waiting a second in a test is a second on every future run. Use a Clock, as module 8 showed.
  • No network, no real database in unit tests. Those are integration tests, run separately.
  • No shared state between tests. JUnit creates a new instance per test; static fields defeat that.
  • @TempDir rather than real paths.

Making code testable

Two habits, both already introduced:

Inject what varies. A class calling LocalDate.now() internally cannot be tested across month boundaries. One taking a Clock can:

BillingService(List<Subscriber> subscribers, Clock clock)

The same applies to file paths, random numbers and anything reaching outside. DeliveryStore takes a Path for exactly this reason — the test passes a @TempDir.

Separate the decision from the effect. A method that computes a report and returns a String is trivially testable; one that computes and prints it is not. renderReport returns text; Main prints it. That split is why the report can be asserted at all.

Test doubles, briefly

When a class depends on something slow or external, pass a fake:

interface MessageGateway { void send(String phone, String text); }

class RecordingGateway implements MessageGateway {
    final List<String> sent = new ArrayList<>();

    @Override
    public void send(String phone, String text) {
        sent.add(phone + ": " + text);
    }
}

A hand-written fake like this needs no library and is clearer than a mocking framework for a small interface. Mockito is what teams use, and it is worth learning when you meet it — but reaching for it before you need it produces tests that assert which methods were called rather than what happened, which is implementation testing wearing a disguise.

This is also the payoff of the inheritance lesson's argument for composition: SmsNotifier holding a MessageGateway can be tested; one extending an HTTP client cannot.

Check your work

What are the three sections of a test? Arrange, act, assert — separated by blank lines, with one act.

What makes a good test name? It describes the behaviour, so a failure tells you what is now untrue before you read any code.

What is the test for whether a test is good? Would it survive a rewrite that kept the behaviour? If a formatting change breaks it, it is asserting on implementation.

Why assert a relationship rather than a literal? report contains the sum of the bills stays true when prices change; a hard-coded total does not.

Name the five things most worth testing. Domain rules; edges; bugs you have fixed; round trips; error paths and their messages.

Name four things not worth testing. Getters and records; the framework; private methods directly; exact log output.

What does coverage tell you? What is definitely untested. It says nothing about how well the rest is tested — a test that asserts nothing still counts.

Why does renderReport return a String instead of printing? Separating the decision from the effect is what makes it testable at all.

Practice 2, the brittle test. Asserting the exact report text passes, then fails the moment a column width changes — with nothing actually broken. Replacing it with assertTrue(report.contains(Money.format(expected))) survives the change. Run the width change against both to feel the difference.

Practice 5, the fake gateway. With a RecordingGateway you assert on sent, and the test runs in microseconds with no network. Writing the same test against a class that constructs its own HTTP client is impossible without either a real server or a mocking framework — which is the composition argument from module 3, arriving as a practical consequence.

Practice

  1. Rewrite three test names. Take three tests called something like testX and rename them to describe the behaviour. Then read the list of method names and see whether it reads as documentation.

  2. Write a brittle test, then fix it. Assert the exact text of a formatted report. Change a column width and watch it fail. Replace it with an assertion on the total.

  3. Test the edges of one method. For Money.format: zero, five paise, a negative, and a value over ten lakh. For tiffinsPerCustomer: an empty list, one delivery, and a month with none.

  4. Turn a bug into a test. Find a bug in your own code from an earlier module. Write the test first, watch it fail, then fix the code.

  5. Write a fake. A MessageGateway interface, a RecordingGateway implementation, and a test asserting that a reminder was sent to the right number. No network, no mocking library.

  6. Harder — audit a suite. Take the tests you have written for the capstone and go through them asking, for each: what bug would this catch? Delete any whose honest answer is "none". Then look at what is left and write down the three most likely bugs it would not catch. Those three are your next tests, and that exercise is worth more than any coverage report.

Next: structuring a Java project so that it still makes sense at ten thousand lines.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship