Code that's not tested is code that breaks on Friday afternoon. This lesson is a conceptual tour of testing: what tests are for, what kinds exist, and the rules that separate a test suite that helps from one that gets ignored. We stay at the level of ideas, with a few small Java snippets to keep things concrete.
A test is a small piece of code that calls your production code with known inputs and checks the result against a known answer. That sounds boring on its own. The reason teams invest in tests is what those small pieces of code give you over time.
The first benefit is regression safety. You change one method to fix a bug, and the test suite tells you whether you accidentally broke five other methods. Without tests, every change carries the question, "did I just break checkout for gold customers?" With tests, the answer comes back in seconds.
The second benefit is design feedback. A class that's hard to test usually has something wrong with its design. If you can't construct a Cart without spinning up a database, the cart is probably doing too much. Writing the test forces you to notice.
The third benefit is documentation. A test named should_applyTenPercentDiscount_when_customerHasGoldStatus is more honest than any comment. It runs. It either passes or fails. Anyone reading the test learns exactly how the production method is supposed to behave, including the corner cases the author thought about.
The fourth benefit is refactor enablement. You see an ugly method and want to clean it up. With a strong test suite, you change it and run the tests. With no tests, you leave it ugly because the cost of breaking something silently is too high. Tests are what let a codebase improve over time instead of slowly rotting.
The cost of no tests is the inverse of all four. Bugs reach production. Changes get scary. Onboarding is slow because nothing documents intended behavior. Refactoring stops. The codebase calcifies, and the team starts dreading every release.
Not all tests are the same shape. The classic mental model is a pyramid with three layers, each one slower and more expensive than the one below.
The pyramid has a wide base of unit tests and a narrow top of end-to-end tests. The shape is deliberate. Fast tests at the bottom run on every save. Slow tests at the top run once per build, or once per nightly cron.
A unit test exercises one class or one method in isolation. It runs in the same JVM, touches no disk, no network, no database. A good unit test finishes in under 10 milliseconds. A whole unit test suite for an e-commerce backend might be ten thousand tests and still finish in under a minute.
An integration test exercises multiple components together. The cart service plus the cart repository plus a real database. The order controller plus the HTTP server plus a fake payment gateway. Integration tests are slower because they cross process or I/O boundaries, but they catch a different class of bug: SQL queries that don't match the schema, JSON serialization that drops fields, transactions that don't commit when you expected them to.
An end-to-end test drives the whole system through its public interface. It opens a browser, navigates to the product page, clicks "Add to cart," fills in shipping details, and verifies that an order appears in the admin dashboard. End-to-end tests are slow (often several seconds each), brittle (a CSS change can break them), and expensive to maintain. You want a handful that cover the most important user journeys, not hundreds.
The shape matters because an inverted pyramid (lots of end-to-end, few unit) is a common default. End-to-end tests feel like they cover more, so why bother with the small ones? The answer is feedback speed and stability. When a unit test fails, you usually have a one-line diff to look at. When an end-to-end test fails, the failure could be in any of fifty components, and it might be a real bug or it might just be flaky.
A 30-second integration test isn't slow for a single run. It's slow when you've 800 of them and want to run the suite on every commit. Test runtime compounds. A test that costs 2 seconds and runs 50,000 times a day across CI agents is 100,000 seconds of compute per day.
A small DiscountCalculator and a hand-rolled unit test for it. The test uses plain Java assertions so the structure is visible without any framework.
That's the smallest possible test. Set up an input. Call the method. Check the answer. The structure has a name, and that name is the most important pattern in this lesson.
Every well-formed test has three sections, in order:
This isn't a framework rule. It's a readability rule. A test written in AAA order tells a story: "Given this setup, when I do this action, then this should happen." A test that mixes these phases together is a test nobody can read.
The diagram is simple because the idea is simple. The discipline becomes valuable when tests get bigger. A slightly larger example, still using plain Java, that follows AAA explicitly with blank lines between the sections.
The comments matter here. In real test code you usually don't write // Arrange and // Act literally, but you keep the order, and you separate the phases with a blank line. The reader's eye then follows the same shape every time.
A common mistake is to interleave the phases: assert a precondition, then arrange more, then act, then arrange yet again. The test becomes a maze. When it fails, figuring out which line was the action takes longer than fixing the bug.
F.I.R.S.T. is a mnemonic for what a good test looks like. The five letters stand for Fast, Independent, Repeatable, Self-validating, and Timely.
Fast. A test should run in milliseconds. The slower a test is, the less often you run it, and the longer the feedback loop becomes. A test suite you run on every save changes how you work. A suite you run once before a commit changes nothing. Speed is what gets you the benefit.
Independent. Tests shouldn't depend on each other. Test A shouldn't need test B to run first. Test C shouldn't leave data behind that test D reads. Independence is what lets the runner parallelize tests, run a single test on its own, and reorder them safely. The moment one test depends on another, the suite becomes a single brittle script.
Repeatable. Running the same test twice with the same code should give the same result. A test that passes on your laptop and fails on the CI server isn't repeatable. A test that depends on the current time, the current weather of the cloud region, or the order entries land in a hash map isn't repeatable. Repeatability is what makes a failure a real signal instead of noise to ignore.
Self-validating. The test, on its own, decides whether it passed or failed. There should be no "open this log file and check whether the number on line 17 looks right." Self-validation is what lets a CI server run thousands of tests and produce a single red or green answer. A test that requires human eyes to check isn't really a test.
Timely. Write the tests close in time to writing the code, not months later. Whether the test comes first (TDD style) or right after, a test written much later misses the corner cases that were fresh in mind when the code was written. Timely doesn't have to mean before, but it does mean soon.
| Principle | Smell | Fix |
|---|---|---|
| Fast | A test that takes 4 seconds to set up a database | Use an in-memory replacement or stub the dependency |
| Independent | Test 7 fails when test 3 is skipped | Make every test build its own data, never share state |
| Repeatable | Test sometimes passes, sometimes fails | Replace LocalDateTime.now() with an injected Clock |
| Self-validating | Test prints results to console and you check them | Replace println with an explicit assertion |
| Timely | Tests written six months after the production code | Write tests within the same PR as the code change |
Slow tests have a compounding cost. A test suite that takes 10 minutes to run gets run once before a push. A suite that takes 10 seconds gets run after every change. The same number of tests, with very different effects on the team.
A test's name is its headline. When a test fails in CI at 2 AM, the name is often the only thing you see in the email. A good test name describes the behavior under test, the condition, and the expected outcome. A common convention reads like a sentence:
Or, equivalently:
A few examples that follow the pattern and a few that don't.
| Name | Verdict | Why |
|---|---|---|
should_returnDiscountedPrice_when_customerHasGoldStatus | Good | Behavior, condition, and outcome are all named |
should_throwIllegalArgumentException_when_quantityIsNegative | Good | Failure case is documented as clearly as the happy path |
applyDiscount_givenZeroPercentage_returnsOriginalPrice | Good | Different word order, same information |
test1 | Bad | Tells the reader nothing |
testDiscount | Bad | Names a method, not a behavior |
worksCorrectly | Bad | "Correctly" is the entire job of every test |
testApplyDiscountWith10PercentAnd100Dollars | Bad | Names the inputs but not the behavior, and is fragile |
A long, sentence-like name makes the failure message self-explanatory. When CI reports should_releaseStock_when_orderIsCancelled FAILED, you know what was being tested and what should have happened. You can start debugging before reading the test body.
A useful refinement: the name should describe what the code does, not how it does it. should_useHashMapToLookUpCustomer is naming the implementation. The HashMap might get replaced by a TreeMap next week, and the test name becomes a lie. should_findCustomerById_inConstantTime is closer to a behavior, though even there you're getting into implementation territory. Aim for behavior-level names so tests don't have to be rewritten every time the internals change.
A useful rule of thumb: each test should focus on one concept. Don't write a test that simultaneously verifies discount calculation, stock release, and order email sending. If any one of those breaks, the test fails, and you've to read the body to figure out which.
This is sometimes phrased as "one assertion per test," which is a stricter version of the same idea. The strict version is debatable. A test that asserts both order.getTotal() == 89.97 and order.getStatus() == PLACED is fine because both checks belong to the same concept ("placing an order produces a placed order with the right total"). A test that asserts order.getTotal() == 89.97 and inventory.getStock("Mug") == 49 is checking two different concepts and probably wants to be two tests.
The reason the rule matters: when a test fails, the test name and the failure message should point at one thing. If a single test can fail for three different reasons, the name has to be three things glued together, and so does your debugging.
A test that violates the rule:
The test passes here, but if a future refactor breaks the status logic, the test fails with "status wrong," and the total and email checks never run. Three small tests, each named for the concept it verifies, would be more useful: should_setTotalToSumOfItems_when_orderIsPlaced, should_setStatusToPlaced_when_orderIsPlaced, should_recordCustomerEmail_when_orderIsPlaced.
Tests need data. A test for placeOrder needs an order. An order needs a cart. A cart needs items. Items need products. Products need prices and stock counts. Building all that by hand in every test produces walls of setup code and hides the one thing the test actually cares about.
The fix is to centralize the boring construction. Three common patterns:
Test data builders use a fluent style to create test objects with sensible defaults, overriding only the fields the test cares about.
The builder reads like English. aCustomer().withGoldStatus().build() says exactly what the test needs and ignores everything it doesn't care about.
Factory methods are a simpler version: a single method that returns a ready-to-use object.
Fixtures are shared setup that runs before tests. JUnit's @BeforeEach is the standard way to declare them. Use fixtures sparingly. Shared state across tests is one of the biggest sources of brittle suites, so the rule is: prefer per-test construction unless the setup is identical and expensive to repeat.
The thing all three patterns avoid is magic numbers. A test that asserts order.getTotal() == 89.97 reads like a riddle: why 89.97? A test that asserts order.getTotal() == itemPrice * quantity reads like an explanation. Compute the expected value from the inputs whenever possible. When you can't (the expected value comes from a calculation the test is verifying), define it as a named constant: double EXPECTED_TOTAL_WITH_GOLD_DISCOUNT = 80.97;.
A builder costs an hour to write the first time. After that, every test that needs a Customer saves ten lines. Across a thousand tests, the math is obvious.
A test suite is a set, not a sequence. Tests can run in any order, on any machine, with any subset selected. If test 7 only passes when test 3 ran first, you've a hidden dependency that will eventually bite you, often when CI parallelizes the suite and runs them across multiple agents.
The two main sources of hidden dependencies:
Shared mutable state at the class or static level. A static currentCart variable that test A sets and test B reads. A static counter that increments. A singleton that holds state across tests. Anything static and mutable in a test is a red flag.
Test data leakage. Test A writes a row into the database and forgets to clean up. Test B happens to query for "any customer" and finds A's leftover row. The two tests pass when run in order. Reverse the order and B fails because A's row hasn't been written yet.
The fix is the same in both cases: each test builds its own world, exercises it, and tears it down. In a unit test, this means constructing the objects you need locally. In an integration test with a real database, this means wrapping the test in a transaction that rolls back at the end, or using a database fixture that resets between tests.
A small example of shared-state breakage. The first test passes, the second test only passes when run after the first.
If a test framework picks them in reverse order, the second test fails immediately because lastCreatedId is null. The fix is to make each test set up the state it needs, without relying on anything left over from elsewhere.
These two terms describe how much the test knows about the implementation.
A black-box test treats the code as a sealed box. It calls a public method with inputs and checks the output. It doesn't know whether the method uses a HashMap, a TreeMap, or a network call. The advantage is that the implementation can change without breaking the test. The disadvantage is that black-box tests have a harder time exercising specific internal paths.
A white-box test sees inside the box. It might assert that a specific helper method was called, that a particular branch was taken, or that an internal data structure has a specific shape. The advantage is precision: you can target the corner case you care about. The disadvantage is brittleness: change the internals and the test breaks, even though the externally observable behavior is identical.
Most production test suites are predominantly black-box, with a few white-box tests where they're useful. The rule of thumb: prefer testing the contract (what callers see) over testing the structure (how it's implemented). When you refactor a method's internals, you want the test suite to nod along, not flash red.
Code coverage measures what percentage of your production code is executed by the test suite. A line-coverage tool reports "85% of lines were touched by at least one test." A branch-coverage tool reports "70% of if branches were exercised by tests."
Coverage is a useful signal. If a key service has 20% coverage, you've a problem. The numbers tell you where the gaps are. The mistake is to treat coverage as the goal. A team that has to hit 90% coverage to merge will hit 90% coverage. They'll do it by writing tests that call methods and don't actually check the results. Coverage goes up; bug-catching power doesn't change at all.
| Coverage type | What it measures | Limitation |
|---|---|---|
| Line | Percentage of source lines executed | A test can execute a line without checking anything |
| Branch | Percentage of if / switch arms taken | Only counts taken arms, doesn't measure correctness |
| Path | Percentage of distinct paths through the code | Combinatorially expensive on real code |
| Mutation | Percentage of intentionally-introduced bugs that tests catch | Slower to run, but the most honest signal |
The honest version of coverage is mutation testing: a tool inserts small bugs into your code (changing + to -, flipping < to >=, removing a method call) and reports which mutations your tests caught. If mutation X survives, you've at least one test that doesn't check anything meaningful. Mutation testing is slow, but the results are far more honest than line coverage.
The practical takeaway: use coverage as a heat map for where to invest in tests, not as a number to hit. A class with 60% coverage and excellent tests for the parts that matter beats a class with 95% coverage and tests that exercise lines without verifying behavior.
A line-coverage report is cheap. A mutation-testing run can take 5-10x as long as the regular test suite. The signal quality is worth it for important services, but it's not something you run on every commit.
Real code has dependencies. The order service depends on the payment gateway. The payment gateway depends on the bank's API. Unit-testing an order service means deciding what to do about all those dependencies, because a real unit test can't actually call the bank.
The general term for "an object that stands in for a real dependency during a test" is test double. Five common kinds, each with a specific role.
A dummy is an object you've to pass to satisfy a parameter list, but the test never uses it. Consider a method createOrder(Customer, Logger) where the test never exercises any code path that uses the logger. A dummy Logger object that does nothing fits the parameter and is never called.
A stub returns canned answers to calls the test makes. A stub InventoryService whose isAvailable method always returns true lets you test the order service's happy path without setting up real inventory. The stub has no logic of its own; it hands back whatever the test asked it to hand back.
A fake is a working implementation that's simpler than the real thing. An in-memory Map-backed CustomerRepository that stores customers without touching a database is a fake. It behaves correctly for the operations the test needs, but it doesn't have the cost or complexity of the real database.
A spy is like a stub that also records what was called. A spy EmailSender returns whatever the test set it to return, and additionally remembers every method call so the test can later assert "send was called once with this address."
A mock is a spy with expectations declared up front. The test says "I expect charge to be called exactly once with this customer and this amount." If the production code calls charge zero times, or twice, or with the wrong amount, the mock fails the test. Mocks are stricter than spies, and they're appropriate when you care about the interaction itself, not just the outcome.
| Kind | Returns values? | Records calls? | Strict about calls? |
|---|---|---|---|
| Dummy | No (never used) | No | No |
| Stub | Yes (canned) | No | No |
| Fake | Yes (computed) | No | No |
| Spy | Yes (canned) | Yes | No |
| Mock | Yes (canned) | Yes | Yes |
A common pitfall is using mocks everywhere, because the testing library makes it easy. The result is a test suite that's tightly coupled to the implementation: every method call is documented in the test, so any refactor that adds or removes a call breaks the tests. Prefer fakes and stubs by default. Use mocks only when the fact that a call was made is the behavior you're testing.
Some methods are easy to test. Some methods are hard. The difference usually comes down to whether the method is a pure function or has side effects.
A pure function depends only on its inputs and produces only a return value. Same inputs, same outputs, no surprises. applyDiscount(100.0, 10.0) always returns 90.0, no matter when you call it, on what machine, in what order. Pure functions are the easiest things in the world to test: call them with inputs, assert on outputs, done.
A side-effectful method does something to the world beyond returning a value. Writes to a database. Sends an email. Modifies a field on an object passed in. Calls another service. Reads the current time. Reads a random number. Side effects make tests harder because the test has to either set up the side-effect's environment, fake it out, or accept that the test is slower and more brittle.
The practical implication is a design hint: the more of your code you can write as pure functions, the easier it's to test. A common pattern is to push the side effects to the edges. The core domain logic (compute discount, validate cart, calculate shipping cost) is pure. The outer layer (read from database, write to database, send email) is thin and is tested with integration tests.
A small contrast. The first version mixes domain logic with side effects. The second separates them.
The second version takes the day as a parameter. A test can pass in whatever day it wants and assert on the result. The first version forces the test to either mock the clock, change the system time, or only pass on Fridays. None of those are good options. Pushing the side effect outwards (making the caller decide what "today" is) turns a hard-to-test method into a trivial one.
Test-Driven Development is a discipline where you write the test before the production code. The cycle is called red-green-refactor: write a failing test (red), write the smallest production code that makes it pass (green), then clean up the design without changing behavior (refactor). Repeat.
TDD is optional. Plenty of excellent code is written test-after rather than test-first. The argument for TDD is that it forces you to think about the contract before the implementation, which usually produces cleaner code. The argument against it's that for unfamiliar problems, you sometimes need to explore the implementation before you know what the test should look like. The honest answer is that TDD is a tool to know, not a rule to follow dogmatically. Pick it up, try it, decide for yourself when it's useful.
A regular test picks specific inputs and asserts on specific outputs. A property-based test describes a property that should hold for many inputs, and a tool generates the inputs for you.
For example, "for any non-negative price and any percentage between 0 and 100, applyDiscount(price, pct) should return a value between 0 and price." A property-based test (using a library like jqwik) generates hundreds of price-and-percentage pairs and checks that the property holds for all of them. When the property fails, the tool shrinks the input down to the smallest example that breaks it.
Property-based tests aren't a replacement for example-based tests. They're a complement. Use them when testing algorithms (sorting, encoding, parsing, math) where you can describe a general property, and use regular tests when the behavior is "given this specific input, return this specific output." We'll stick to example-based tests for the rest of this section.
Even with good intentions, test suites accumulate smells. The five worst:
Brittle tests break for the wrong reasons. A change to an unrelated part of the code breaks a test that didn't actually depend on it. Symptom: every refactor results in a wave of test changes that don't reflect a real behavior change. Cause: usually too much white-box testing, too many mocks asserting on internal calls, or assertions on details that aren't part of the contract. Fix: assert on the observable behavior, not the implementation.
Slow tests drag the team down. A "unit" test that talks to a real database is misnamed. A unit test that sleeps for 500ms to "wait for things to settle" is a unit test that's actually an integration test in disguise. Fix: remove I/O, remove sleeps (use proper synchronization), use fakes instead of real dependencies.
Flaky tests pass sometimes and fail sometimes, with no code change in between. The team learns to re-run them until they pass, which means real failures get re-run and ignored too. Causes: race conditions, dependence on wall-clock time, dependence on filesystem state, ordering inside hash-based collections, network timeouts. Fix: make the test deterministic, even if that means more setup code.
Mystery guest is when a test depends on data it didn't set up: a row that "should be there" from a fixture file, a system property that "should be set" in the environment. The test passes locally and fails on CI, or vice versa. Fix: every test sets up the state it needs, period.
Eager test tries to verify too many things at once and ends up verifying none of them well. Fix: split into multiple tests, each with a single focus.
The common thread in all five smells is that they reduce trust in the test suite. A team that trusts its tests runs them often, listens when they fail, and writes more of them. A team that doesn't trust its tests ignores red results, re-runs flaky ones, and eventually stops writing new tests at all. Each smell, left unfixed, slowly converts the first team into the second.
A healthy test suite has shape. It looks like a pyramid: many unit tests, fewer integration tests, a handful of end-to-end tests. Each test follows AAA. Each test name describes a behavior in plain English. Each test sticks to one concept. Each test is independent of every other. Test data is built with builders or factories, not hand-rolled in every file. Side-effectful code is squeezed out to the edges so the core domain stays pure and easy to test. Coverage is watched but not worshipped. Test doubles match the role they play: fakes for working dependencies, stubs for canned answers, mocks only when the interaction itself is the thing under test.
None of this is about JUnit or Mockito specifically. These principles apply whether you write tests in Java, Python, or anything else.
9 quizzes