A unit test checks a small piece of application behavior with controlled inputs. For example, we can give a delivery fee calculator an order amount and check the fee it returns. We do not need to start the website or place a real order to check that rule.
Useful unit tests make the rule clear, fail when it is broken and run independently of other tests. They also protect against regressions, where a change breaks behavior that previously worked. They cannot prove that database queries, application configuration and remote services all work together, so we use integration tests for those questions.
This guide uses JUnit Jupiter, the programming model used to write tests in modern JUnit. The examples assume a Java 17 or later project that already has JUnit Jupiter configured for tests.
1. Start With One Clear Behavior
We will test a delivery fee calculator with two rules. Orders with a subtotal of at least 5,000 cents receive free delivery. Smaller orders pay 499 cents. Negative subtotals are invalid.
The production class contains only this calculation.
public final class DeliveryFees {
public int forSubtotal(int subtotalInCents) {
if (subtotalInCents < 0) {
throw new IllegalArgumentException("Subtotal must not be negative");
}
return subtotalInCents >= 5000 ? 0 : 499;
}
}
For a conventional Maven project, we put this class in src/main/java and its tests in src/test/java, under matching packages. We use integer cents here to keep attention on the delivery rule.
The following test checks the exact free-delivery threshold.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class DeliveryFeesTest {
private final DeliveryFees fees = new DeliveryFees();
@Test
void givesFreeDeliveryAtTheThreshold() {
int subtotalInCents = 5000;
int fee = fees.forSubtotal(subtotalInCents);
assertEquals(0, fee);
}
@Test
void chargesDeliveryBelowTheThreshold() {
int fee = fees.forSubtotal(4999);
assertEquals(499, fee);
}
@Test
void rejectsNegativeSubtotals() {
assertThrows(IllegalArgumentException.class,
() -> fees.forSubtotal(-1));
}
}
Each \@Test method describes a separate behavior. The tests should pass for the class above. If we accidentally change >= to >, the threshold test should fail because it receives a fee of 499 instead of 0. The test below the threshold should still pass. This tells us which part of the rule changed.
2. Make Arrange, Act and Assert Easy to See
Many tests follow three parts. We arrange the starting data, act by calling the operation and assert the expected result. This is often called the AAA pattern.
In the threshold test, assigning 5,000 cents arranges the input. Calling forSubtotal() performs the action. assertEquals() checks the fee.
We do not need a comment above every line to enforce this pattern. Blank lines and clear names are often enough. The purpose is to make the condition and the result easy to find, especially when setup becomes more involved.
The expected value should come from the requirement. If we calculate it by copying the production formula into the test, the same mistake can appear in both places. A known value such as 499 is useful because we can compare the implementation with an independent expectation.
3. Cover Boundaries and Important Failure Cases
Normal cases tell us that the common path works. Boundary cases help us distinguish similar rules, such as “at least 5,000” and “more than 5,000.”
For this calculator, useful inputs include zero, just below 5,000, exactly 5,000, above 5,000 and a negative amount. We select cases because they exercise a meaningful condition, not simply to increase the test count.
Parameterized tests let us run the same check with several inputs. They are useful when the setup and assertion are genuinely the same for every row.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@ParameterizedTest
@CsvSource({"0, 499", "4999, 499", "5000, 0", "5001, 0"})
void calculatesDeliveryFee(int subtotal, int expectedFee) {
DeliveryFees fees = new DeliveryFees();
int actualFee = fees.forSubtotal(subtotal);
assertEquals(expectedFee, actualFee);
}
This method can replace the two successful-path methods in DeliveryFeesTest. It uses the existing static import of assertEquals(). The project’s JUnit dependencies must include parameterized-test support, commonly through junit-jupiter or junit-jupiter-params.
4. Keep Tests Independent
Each test needs to establish its own starting state. An update test must not rely on a create test running first. Otherwise, running the update test by itself gives a different result from running the whole class.
We create fresh mutable objects for each test. By default, JUnit Jupiter creates a new test-class instance for each test, so the fees field in our example is initialized again for every test. A per-class lifecycle changes that assumption. Common setup can use \@BeforeEach, which runs before each test method. \@BeforeAll runs once for the class and is suitable for carefully managed shared setup, not for resetting every test’s mutable state.
Static constants are fine. Shared mutable fields need more care because one test can leave behind changes that affect another. We avoid shared mutable state when a fresh object is inexpensive.
The FIRST checklist explains independence and repeatability in more detail.
5. Assert One Behavior With the Checks It Needs
We keep unrelated scenarios in separate tests, but we do not force every test to have exactly one assertion. A returned address may need checks for its street, city and postal code. Those values describe one conversion result.
We also choose assertions that express the requirement. For JUnit’s assertEquals(), the expected value comes before the actual value. For an ordered collection, assertIterableEquals() compares the sequence. An unordered result needs an assertion that does not accidentally depend on iteration order.
JUnit and AssertJ are different assertion APIs. Assertions.assertThat() belongs to AssertJ, not JUnit Jupiter. We can use either library deliberately, but we keep imports and examples consistent. The JUnit assertion guide documents Jupiter’s available methods.
6. Check Expected Exceptions Directly
In the negative subtotal test, assertThrows() fails if the calculator returns normally or throws a different, unrelated exception type. It accepts the specified type or a subtype. When an exact exception class is part of the requirement, Jupiter also provides assertThrowsExactly().
We can inspect the exception returned by the assertion when an error code or message is part of the contract. We avoid asserting an entire implementation-specific message when a small wording change should not affect callers.
For normal success cases, we let unexpected exceptions fail the test. We do not need a catch block that prints an error and allows the method to finish successfully.
7. Use Test Doubles for Dependencies, Not for the Behavior Under Test
A test double is an object that replaces a dependency during a test. A stub can return a prepared answer, while a mock can also check an interaction.
For example, an order service unit test can use a payment gateway double to simulate a declined payment. That lets us check the service’s response without making a real payment. We still run the real order service method.
If we stub that method itself and assert the value we told it to return, the test has not checked the service. Similarly, mocking a repository does not validate its SQL or MongoDB query. The DAO testing guide covers those integration tests.
We also avoid checking every internal method call unless the interaction matters. A test that verifies harmless implementation details can break during a refactor even when the required behavior stays correct.
8. Control Time, Files and Asynchronous Work
We pass a fixed java.time.Clock, which supplies a controlled current time, to time-dependent code. We use temporary directories for file tests and provide explicit inputs instead of relying on one developer’s environment.
For asynchronous code, a fixed sleep can be too short on a busy machine and wasteful on a fast one. We wait for a defined result with a bounded timeout. Where practical, we test the core calculation synchronously and test the asynchronous coordination separately.
Randomized tests can be useful, but failures must be reproducible. We record the seed or generated input rather than losing the case that exposed the failure.
9. Test Public Behavior Without Ignoring Configuration
We usually exercise private helpers through the public behavior that uses them. If a private helper has substantial independent logic, a separate component may make that logic easier to name and test.
Configuration also affects application behavior. We do not need to repeat every property value in every unit test, but loading, validating and applying important configuration can deserve tests. A Spring context test may be appropriate for bean wiring, while a plain unit test can check a configuration parser.
The test type follows the question. A unit test is not a substitute for every integration check, and an application startup test is not a substitute for focused business-rule tests.
10. Keep the Suite Useful During Everyday Development
We run the relevant tests while making a change and include the suite in the build. In a configured Maven project, mvn test runs the unit-test phase. Integration tests may use a separate phase or plugin configuration, so we check the project’s build rather than assuming one command discovers everything.
Build automation can publish reports showing failed tests and their assertions. Diagnostic logs can help investigate a failure, but reading those logs must not be the pass-or-fail check itself.
A disabled test needs a reason, an owner and a plan to restore or remove it. A flaky test sometimes passes and sometimes fails without a relevant code change. Temporarily disabling one may be necessary, but silently skipping it leaves a gap. We fix the cause instead of treating repeated reruns as a solution.
11. Use Coverage as a Clue
Code coverage measures which code ran during tests. It does not measure whether the tests checked the right results. A test can execute a method without detecting a wrong answer.
We use coverage to find untested branches, then ask what behavior those branches represent. We pay particular attention to business rules, boundaries and failure handling. There is no single coverage percentage that proves a suite is reliable.
For a suspicious test, we can briefly introduce a known wrong result and confirm that the test fails, then restore the implementation. Mutation-testing tools automate variations of that idea. The guide to poor unit tests describes common ways a green test can provide little evidence.
12. Conclusion
A useful unit test describes a behavior, controls its inputs and checks an outcome that matters. We keep tests independent, cover meaningful boundaries and use integration tests for real component interactions. That combination gives us clearer failures and better protection when the application changes.
Happy Learning!
How do I write a unit test case for methods which involves checking date and the test case passes in the future too?
for eg: One of my methods retrieves those data from db for which a certain date is in future.
In my assert statement I am checking for the size of the list to pass the test case.
But once that date is past, the test case would fail later in future runs.
Could you please tell me how do I solve this.
Lokesh, Thanks for your effort, first.
May I know the difference between:
“Ensure that test code is separated from production code” AND “Integrate Testcases with build script”?
Thanks
I assume He means to create a test package for that project, write your test cases there, and use build tools like maven, Gradle or ant to test cases while building the project automatically.
Hi Lokesh,
I am facing problem writing unit test. One of the my class one method having class implementation that not going to inside class
example:
public CommandResult execute(final String commandName, final Map configuration, Application app, ConnectorState state) throws ConnectorException {
Work work = new Work() {
@Override
protected boolean work() throws Exception {
————————-
});
Please share your inputs on this.
It’s really very hard to say anything based on this limited information. Please share in detail.
In developer profile, when I do CLEAN_INSERT while performing a maven build, it drops database and create fresh tables using hbm2ddl mapping.
Now when test cases are run, I need test data to be imported in local database from another database instance like QA or UAT. So, that I do not have to prepare test data in xml file.
Can anyone specify how can I do this ??
I agree!
testing all private methods implies that if you refactor a method, you suddenly need additional unit tests… but one of the big goals of unit testing is to be confident in your refactoring. A refactor shouldn’t require additional tests, and private methods are generally the result of a refactor.
But on the whole, this was a very well written article which can be very helpful.
Hi,
Thanks for a great article, i just found your website and it has a lot of good information. I wanted to do some tests for the application i am currently working on but the first step seems to be the hardest. Since i am working with a legacy application i will start writing tests for the bugs that i assigned. I will come back with another comment after i have done some tests.
Thanks again for the article and for this website.
Hi, this is a nice article,
but i do have some questions:
in regards to the Unecessary Assertions, could you please give an example of what you understand as a case of how not to use it? hope to hear from you.
Hi Ruben, When I am saying that “do not make unnecessary assertions”, I suggest to focus on only specific feature of a test unit.
Lets say, I am writing test cases for a login(email, password) function. While writing testcase for this function following things can be asserted.
1) Login succeeded? [First assert : response code OK]
2) Users basic details returned in response. [Second assert : verify detail]
I will suggest to write two separate testcases for both cases.
Writing only single testcase can create confusion that whether
1) email/password logic is broken or
2) data retrieval logic after successful login is faulty.
A testcase should always be enough to point out the exact reason of failure without debugging the code.
Hope, I make sense to you.
Hi Lukhe,
Please if you can give me an example or link to accomplish this?
many thanks