Testing your design

Part of the free Low-Level Design course on LogicWiz, in the chapter: Code that survives review. The class-diagram exercises run in the app.

Testing your design

"If I cannot write a test for this in five lines, the design is asking you to change it."

Priya, reviewing Arjun's pull request


A test is the first user of your design

Arjun wrote a late-night delivery fee for SwiftKart. After 10 pm the fee is 40 rupees instead of 25. Priya asked for one test.

public class LateNightFee implements FeeStrategy {
    public long fee(Order order) {
        int hour = LocalTime.now().getHour();   // reads the real clock
        return (hour >= 22 || hour < 6) ? 4000 : 2500;
    }
}

Arjun's test expected 4000 and passed when he ran it at 11:05 pm. At lunch the next day it failed. Nothing in the code had changed. The class asks the real clock for the time, and a test cannot choose the time.

Arjun said: "Tests are extra work after the design is done. The interviewer only runs my main." Priya answered with a different idea. A test is the first other user of your class. If the class is hard to use from a test, it will be hard to use from anything else.

Four habits make code hard to test:

  • It creates its own helpers with new, such as new UpiGateway() inside a method.
  • It reads the clock, a random number or a global variable directly.
  • It does many jobs, so one test must set up everything.
  • It talks to the network or a disk, so tests are slow or fail by chance.

These are the same problems the SOLID chapter named. So testable design and good design are the same thing. The test only makes the pain visible in five minutes. Think of a mechanic who can check one engine part on a bench only if the part can be unbolted. The comparison stops working at one point: a part can be easy to unbolt and still be a poor part. Easy to test is necessary for good design, but it is not enough.

Your first test with JUnit

A unit test is a small program that runs one piece of your code and checks the result. It uses no network and no disk, so it finishes in milliseconds. JUnit is the standard Java library for writing them. This lesson uses its org.junit.jupiter.api package, introduced in JUnit 5 and still used by the current release.

class StandardFeeTest {
    private final StandardFee fee = new StandardFee();

    @Test
    void orderBelow199RupeesPaysTheDeliveryFee() {
        Order order = new Order("O1", "C1", 19899);   // arrange

        long charged = fee.fee(order);                // act

        assertEquals(2500, charged);                  // assert
    }

    @Test
    void orderOf199RupeesDeliversFree() {
        assertEquals(0, fee.fee(new Order("O1", "C1", 19900)));
    }
}

Read it piece by piece:

  • @Test marks a method that JUnit runs. The method has no parameters and returns nothing.
  • Each test has three steps: arrange (build the objects), act (call the code once), assert (check the result). Blank lines between them make the shape easy to see.
  • assertEquals(expected, actual) passes when the two values are equal. Put the expected value first. A failure then reads expected: <2500> but was: <0>, which tells you which side is wrong.
  • assertThrows(SomeException.class, () -> code) passes only if the code throws that exception. An exception is how Java code says "this cannot be done". assertTrue and assertFalse check a condition.

Two habits matter more than the syntax. First, one behaviour per test. When a test named orderOf199RupeesDeliversFree fails, you know what broke before you open the file. Second, name a test like a requirement: what happens, and when. test1 tells nobody anything. Notice that the two tests use 19899 and 19900, the two sides of the boundary, because that is where mistakes hide.

In IntelliJ, check the dependency first. Per the JetBrains documentation, for a Maven project open pom.xml, press Alt+Insert, choose Dependency, search for org.junit.jupiter:junit-jupiter and add it. The Code menu has a Generate command if the shortcut differs on your system. Then reload the Maven project. In pom.xml the result looks like this:

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>6.1.3</version>
  <scope>test</scope>
</dependency>

With no build tool, open File, Project Structure, Libraries, add a new project library From Maven, and enter org.junit.jupiter:junit-jupiter:6.1.3. To write a test, put the caret on the class name, press Alt+Enter and choose Create test. To run, click the green arrow in the gutter beside a test method or the class. Results appear in the Run tool window. JUnit 6 needs Java 17 or later.

Take time and outside services from outside

Back to LateNightFee. Give it a Clock, the java.time object that answers "what time is it now?". The real one comes from Clock.system(zone). A frozen one comes from Clock.fixed(instant, zone).

public class LateNightFee implements FeeStrategy {
    private final Clock clock;

    public LateNightFee(Clock clock) {
        this.clock = clock;
    }

    public long fee(Order order) {
        int hour = LocalTime.now(clock).getHour();
        return (hour >= 22 || hour < 6) ? 4000 : 2500;
    }
}

// in a test
Clock night = Clock.fixed(Instant.parse("2026-11-12T17:30:00Z"),
        ZoneId.of("Asia/Kolkata"));            // 11:00 pm in India
assertEquals(4000, new LateNightFee(night).fee(order));

Now the test says exactly when "now" is. It passes at lunch and at midnight. This is dependency injection from the SOLID chapter, used for time. If only one method needs the time, an int hour parameter works too. The caller reads the clock once and the method stays a plain calculation.

Outside services need the same treatment. CheckoutService charges a PaymentGateway. A real gateway is slow, costs money and can be down. In a test you pass a stand-in. Any stand-in is called a test double, and there are four common kinds:

Double What it does SwiftKart example
Dummy Fills a parameter and is never used A gateway that throws if it is ever charged
Stub Returns fixed answers A fee strategy order -> 1000, or a gateway that always declines
Fake A simple working version InMemoryOrderRepository
Spy Records calls so the test can check them A gateway that keeps a list of amounts charged

Libraries exist that build these for you. In a round you cannot count on one being installed, and for an interface with one or two methods a hand-written fake takes ten lines. It also reads as ordinary Java. If your fake needs fifteen methods, the interface is too big, and the interface segregation lesson already told you to split it.

public class FakePaymentGateway implements PaymentGateway {
    final List<Long> charges = new ArrayList<>();
    boolean approve = true;

    public boolean charge(String customerId, long amountPaise) {
        charges.add(amountPaise);                 // spy: remember the call
        return approve;                           // stub: the test chooses
    }
}

With it, two tests cover the checkout rules without any network:

@Test
void chargesSubtotalPlusFee() {
    long charged = checkout.checkout(new Order("O1", "C1", 15000));

    assertEquals(16000, charged);
    assertEquals(List.of(16000L), gateway.charges);
}

@Test
void declinedPaymentThrowsAndSavesNothing() {
    gateway.approve = false;

    assertThrows(IllegalStateException.class, () -> checkout.checkout(order));
    assertTrue(orders.findById("O1").isEmpty());
}

Testing the extension point

Marketing asks for a festival fee: delivery is free. Under the strategy pattern, you add one class and one test class.

public class FestivalFee implements FeeStrategy {
    public long fee(Order order) {
        return 0;
    }
}

class FestivalFeeTest {
    @Test
    void deliveryIsFreeEvenForASmallOrder() {
        assertEquals(0, new FestivalFee().fee(new Order("O1", "C1", 5000)));
    }
}

No old test changed. CheckoutServiceTest builds its service with the stub fee order -> 1000, not with StandardFee, so it checks checkout and nothing else. A new fee rule cannot break it. This is the open/closed principle applied to tests: a new strategy adds a test and edits none. If adding a rule forces you to edit old tests, either those tests are tied to one real rule, or the design hides an if that should be a strategy.

Test-driven development, honestly

Test-driven development (TDD) means you write the test before the code. You repeat a loop of three steps: red (a failing test), green (the least code that passes) and refactor (tidy, with the tests as a safety net). Try it on a coupon: SAVE50 takes 50 rupees off an order of 300 rupees or more.

  1. Red. Write takesFiftyRupeesOffAnOrderOfThreeHundred. Create FlatOffCoupon with discount returning 0. Run it. It fails with expected: <5000> but was: <0>.
  2. Green. Change the method to return 5000;. This looks silly. It proves the test can pass and shows what is still unproven.
  3. Red again. Add givesNothingBelowThreeHundred, which expects 0 for 29999. It fails against return 5000;.
  4. Green. Write subtotalPaise >= 30000 ? 5000 : 0.
  5. Refactor. Move 5000 and 30000 into constructor fields. Run the tests. They stay green.

Look at step 1 again. The second test would have passed against the first version, which returned 0. A test that passes before the code exists checks nothing. Watching each test fail first is the real value of "red".

TDD helps when a rule has clear inputs and outputs, like a fee, a coupon or a state change. It helps less while you are still exploring which classes you need. Interviewers do not require it. Writing the test right after the rule is also honest work.

What to test in 90 minutes, and what a reviewer notices

In a machine-coding round you have little time. Write a main demo that prints one happy path and one failure. Then add three to five targeted tests:

  • The rule with the most branches, such as the fee or the coupon.
  • One boundary, such as 19899 against 19900.
  • One failure path, such as a declined payment saving nothing.
  • The extension point, such as a new strategy.

Skip getters, constructors and the in-memory repository. A reviewer reads the test names first. They check that names read like requirements, that each test has one reason to fail, that no test sleeps or reads the real clock, and that tests do not depend on each other's order. They also check that adding a rule left old tests alone.

Now make the checkout testable on the board. The starter creates a real UPI gateway inside the service and reads the time itself.

📌 Summary: A design that is hard to test is hard to use. Pass in the clock and outside services, use hand-written fakes, test one behaviour per test, and add one test per new strategy.

What Arjun learns next

His code now has tests, and the pull request passes review on that point. Priya's next comment is about the files. The classes are in one folder, with the order flow spread across a service, a map and a main method. Next: where each kind of class lives, which class may depend on which, and how to keep ids, money and time under control.

Check your understanding

  1. Arjun's late-night fee test passed at 11 pm and failed at lunch the next day. What is the design problem?
  2. A test passes in a gateway whose only job is to remember every amount it was asked to charge, so the test can read the list afterwards. Which test double is that?
  3. Marketing asks for a FestivalFee strategy. CheckoutServiceTest builds its service with the stub fee `order -> 1000`. Which existing tests must change?
  4. In test-driven development you write a new test and it passes at once, before you change any code. What should you do?
  5. You have 90 minutes in a machine-coding round and can write only a few tests. Which plan is best?