RizTech Academy logo
RizTech Academy
Capstone: Tiffin Service TrackerLesson 5 of 530 min

Tests, coverage of what matters, and packaging a jar

The application works. This lesson makes it something you would be willing to change — which is what a test suite actually buys — and packages it into a single file you can hand to somebody.

What the suite covers

Thirty-six tests across four classes, and the distribution is the point:

Class Tests Covering
MoneyTest 16 Formatting edges, parsing, invalid input, the round trip
SubscriberTest 7 Validation rules and their messages, plan parsing
DeliveryStoreTest 5 Bad rows, missing file, round trip, atomic write, BOM
BillingServiceTest 8 Month filtering, prices, busiest day, empty month
[INFO] Running com.riztech.tiffin.BillingServiceTest$EmptyMonth
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.003 s
[INFO] Running com.riztech.tiffin.MoneyTest
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.045 s
[INFO] Running com.riztech.tiffin.DeliveryStoreTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 s
[INFO] Running com.riztech.tiffin.SubscriberTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.004 s
[INFO] Results:
[INFO] Tests run: 36, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time:  1.340 s

1.3 seconds. That number matters more than the count: a suite this fast gets run on every save. One taking a minute gets run before a commit; one taking ten gets run by the CI server, alone, and its failures get read tomorrow.

Note what has no tests at all: Main, Plan's accessors, and the records' generated methods. Main is dispatch and printing, thin enough to read; testing plan.label() returns its label tests the compiler.

The four kinds of test worth having

Domain rules. Every "must" in the plan:

@Test
void rejectsAShortPincodeAndQuotesIt() {
    IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
            () -> new Subscriber("Priya", "41120", Plan.VEG, START));
    assertEquals("pincode must be six digits, got [41120]", e.getMessage());
}

Edges. Zero, one, empty, maximum, just over:

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

@Nested
@DisplayName("an empty month")
class EmptyMonth {

    @Test
    void billsNothing() {
        assertTrue(billing.billsFor(DELIVERIES, YearMonth.of(2026, 1)).isEmpty());
    }

    @Test
    void stillRendersAReportWithAZeroTotal() {
        String report = billing.renderReport(DELIVERIES, YearMonth.of(2026, 1));
        assertTrue(report.contains("Rs 0.00"), report);
    }
}

The empty month is the case that would otherwise produce a blank report or a crash, discovered in January.

Relationships, not literals:

@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 know what a tiffin costs. Change every price and it still passes, because the requirement is that the total equals the sum of the parts. A hard-coded Rs 478.06 would fail for no reason, and failing for no reason is how a suite loses its audience.

Round trips, which is where the real bug was:

@Test
void roundTripsThroughSaveAndLoad() {
    List<Delivery> original = List.of(
            new Delivery(LocalDate.of(2026, 9, 1), "Priya", 2),
            new Delivery(LocalDate.of(2026, 9, 2), "Kale, Kavita", 3));

    store.save(original);

    assertEquals(original, store.load().rows());
}
@Test
void roundTrips() {
    for (long paise : new long[]{0, 5, 100, 8235, 214110, 123456789}) {
        String formatted = Money.format(paise).replace("Rs ", "");
        assertEquals(paise, Money.parseRupees(formatted), "round trip failed for " + paise);
    }
}

Two halves agreeing is a property neither half can be tested for alone.

What is deliberately not tested

Worth stating, because the honest list is short and the temptation to test everything is strong:

  • Main. Argument dispatch and printing. Testing it means capturing System.out, which is more test machinery than the code deserves. If Main grew logic, that logic would move into a service and be tested there.
  • Generated record methods. equals, hashCode, toString, accessors.
  • Jackson, Maven, the JDK. They have their own tests.
  • The exact report layout. Column widths are asserted nowhere, on purpose.

Coverage was not measured and no number was targeted. Coverage tells you what is definitely untested; it says nothing about the quality of what is tested. The question asked of each test here was "what bug would this catch", and anything without an answer was not written.

Packaging

mvn clean package
[INFO] Building jar: target/tiffin-tracker-1.0.0.jar
[INFO] Replacing target/tiffin-tracker.jar with target/tiffin-tracker-1.0.0-shaded.jar
[INFO] BUILD SUCCESS
[INFO] Total time:  7.158 s
-rw-r--r--    19234  target/tiffin-tracker-1.0.0.jar
-rw-r--r--  2447994  target/tiffin-tracker.jar

19 KB of your code; 2.4 MB with Jackson inside. The second one runs anywhere there is a JDK:

java -jar target/tiffin-tracker.jar help

mvn package runs the tests first, so a failing test means no jar. That is the whole point of the lifecycle — you cannot accidentally ship a build that does not pass.

The README

# Tiffin Tracker

A command-line tracker for a Pune tiffin service. Records deliveries and
produces monthly bills.

## Build
    ./mvnw clean package

## Run
    java -jar target/tiffin-tracker.jar help
    java -jar target/tiffin-tracker.jar add 2026-09-01 Priya 2
    java -jar target/tiffin-tracker.jar list
    java -jar target/tiffin-tracker.jar report 2026-09

## Data
CSV at ./deliveries.csv, or -Dtiffin.data=<path>

## Test
    ./mvnw test

## Exit codes
0 success · 1 usage · 2 bad input · 3 I/O failure · 4 finished with bad rows

Build, run, data, test. Plus the exit codes, because a tool with meaningful ones should document them.

Add the Maven wrapper — mvn wrapper:wrapper — and commit it, so whoever clones this needs only a JDK.

What you have built

Roughly 420 lines of application and 280 of tests. Every module is in it except module 9, deliberately:

Module In the capstone as
2 long paise, %02d, String.formatted, text blocks
3 Plan enum, Subscriber and Delivery records, compact constructors
4 Parsed<T>
5 TreeMap for a sorted report, the indexed loop where the index is needed
6 groupingBy, summingInt, Optional, ifPresent
7 Messages naming the line and value, UncheckedIOException with a cause
8 Files, atomic move, LocalDate, YearMonth, BOM stripping
10 Maven, JUnit, @TempDir, @ParameterizedTest, exit codes

And one thing that is not from any lesson: a bug found by a test. The store wrote quoted CSV and read it with a plain split, and a customer named Kale, Kavita vanished between saving and loading. That is what the suite is for, and it found it on the first run.

Where to take it next

In rough order of value:

  1. Subscriber management — customers add, loading them from their own CSV with the same Parsed<T> machinery.
  2. --format json on the report, using Jackson and the records you already have. Almost free, and it makes the tool scriptable.
  3. A Clock injected into BillingService, so report with no month argument is testable.
  4. Column-name lookup in the loader, so a re-saved spreadsheet still works.
  5. A web version — Spring Boot, the same domain classes, BillingService behind an HTTP endpoint. That is the Spring course, and the domain you have written moves across unchanged.

Point 5 is the thing worth noticing. Money, Plan, Subscriber, Delivery and BillingService have no idea they are in a command-line application. Only Main and DeliveryStore know anything about how this program is used. That separation is why the next framework you learn will feel like learning a framework rather than learning programming again.

Check your work

Why does the suite's speed matter more than its size? A suite running in a second gets run on every save. One taking ten minutes gets run by a machine, alone, and its failures get read tomorrow.

Why is Main not tested? It is dispatch and printing. Testing it means capturing System.out, which is more machinery than the code deserves — and if it grew logic, the logic would move to a service.

Why assert that the report contains the sum rather than a literal total? The requirement is that the total equals the sum of the parts. A literal fails whenever a price changes, for no real reason.

What can a round-trip test find that testing either half cannot? The two halves disagreeing — which is exactly the bug it found here.

Why was coverage not measured? It says what is definitely untested, not what is tested well. The question asked of each test was "what bug would this catch".

What does mvn package do before building the jar? Runs the tests, so a failure means no jar.

Why are there two jars, and which one do you hand over? The plain one has only your classes; the shaded one includes Jackson. Hand over the shaded one.

Which classes would survive a move to a web application unchanged? Money, Plan, Subscriber, Delivery, Parsed and BillingService. Only Main and DeliveryStore know how the program is used.

Practice 2, the deliberate break. Changing MAX_PER_DAY from 4 to 3 should fail DeliveryStoreTest — the bad-row test asserts the message contains between 0 and 4. Changing a plan price should fail nothing, because no test hard-codes a price. If changing a price breaks a test, that test is asserting a literal where it should assert a relationship.

Practice 4, the missing test. The most likely uncaught bug is in Main — an argument in the wrong order, or the month defaulting when it should not. The fix is not to test Main but to move the decision out of it: parse the arguments in a small Command type that run dispatches on, and test that.

Practice

  1. Run the whole suite. mvn clean test. Note the count and the time.

  2. Break things deliberately. Change MAX_PER_DAY from 4 to 3 and see which tests fail. Then change a plan price and see which fail. Explain the difference between the two results.

  3. Package it. mvn clean package, then run the jar from a different directory with -Dtiffin.data pointing somewhere new. Confirm it creates the file and works.

  4. Find the gap. Write down the three most likely bugs your suite would not catch. Write a test for one of them.

  5. Write the README and add the wrapper. Then delete your target/ directory, follow your own README from scratch, and fix anything that does not work.

  6. Harder — add JSON output. report --format json producing the same data through Jackson, using records for the output shape. Write a test asserting the JSON total matches the text total. Then note how much of the application you had to change: if it is more than Main and one new record, the layers need another look.


That is the course. Fifty-five lessons, eleven modules, and an application that records deliveries, refuses bad data, reports every problem it finds, bills a month correctly in paise, has a suite that runs in a second, and packages into one file.

You can read a stack trace, choose between a record, an enum and a class with reasons, write a stream and know when not to, handle a file without losing data to a crash, recognise a race condition, and lay out a Maven project that still makes sense when it grows.

The next step is Spring Boot, where BillingService sits behind an HTTP endpoint and the domain you have written moves across without a change. You will find it is a framework to learn, not a language — which is exactly what these eleven modules were for.

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