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 capturingSystem.out, which is more test machinery than the code deserves. IfMaingrew 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:
- Subscriber management —
customers add, loading them from their own CSV with the sameParsed<T>machinery. --format jsonon the report, using Jackson and the records you already have. Almost free, and it makes the tool scriptable.- A
Clockinjected intoBillingService, soreportwith no month argument is testable. - Column-name lookup in the loader, so a re-saved spreadsheet still works.
- A web version — Spring Boot, the same domain classes,
BillingServicebehind 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
-
Run the whole suite.
mvn clean test. Note the count and the time. -
Break things deliberately. Change
MAX_PER_DAYfrom 4 to 3 and see which tests fail. Then change a plan price and see which fail. Explain the difference between the two results. -
Package it.
mvn clean package, then run the jar from a different directory with-Dtiffin.datapointing somewhere new. Confirm it creates the file and works. -
Find the gap. Write down the three most likely bugs your suite would not catch. Write a test for one of them.
-
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. -
Harder — add JSON output.
report --format jsonproducing 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 thanMainand 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