Chapter 21 — Testing with pytest-style Thinking
Section 1: What Testing Is For
Testing is the habit of checking your program with known examples before you trust it with real work. You already test informally every time you run a program and look at what it prints, but informal checking is easy to forget, easy to repeat badly, and easy to skip when you are in a hurry. A written test turns that check into something repeatable: a small piece of code that runs a function with a chosen input, compares the result with the result you expect, and reports success or failure without needing you to stare at the output and judge it yourself. That repeatability is the whole point. Once a test exists, you can run it again after every change, and it will catch a mistake you introduced even if you were thinking about something completely different at the time.
Beginners sometimes think testing is only for large programs or professional teams, but small programs benefit just as much, because small programs still contain assumptions. You assume a function handles an empty list, a negative number, or a name with a space in it, and those assumptions are exactly where bugs hide. A test forces you to state the assumption plainly. If you write a function that adds tax to a price, a test says, in effect, that a price of one hundred with a ten percent rate should give one hundred and ten, and it says it in a form the computer can verify. That clarity helps you too, because writing the test often reveals that you were not quite sure what the function was supposed to do in a tricky case.
Testing also changes how you feel about changing code. Without tests, every edit feels risky, because you cannot be sure what else you might have broken. With a good set of tests, you can refactor, tidy up, and improve a program with much more confidence, because the tests will speak up if a change alters behaviour that mattered. This does not mean tests make a program perfect. A program can pass all its tests and still be wrong, if the tests themselves encode the wrong expectation or if nobody tested an important case. Testing is evidence, not proof, and honest testers say so. It raises your confidence in proportion to how well the tests cover the behaviour that matters, and it gives you a safety net that grows as the program grows.
Finally, testing is a communication tool. A new person reading your tests learns quickly what each function is supposed to do, which inputs are normal, and which inputs are special. Tests are examples that cannot drift out of date silently, because they run. Documentation can become stale without anyone noticing, but a stale test fails loudly. That is why experienced developers treat tests as part of the program, not as an optional extra to be written if time remains. Time rarely remains, so the practical rule is simple: write the test alongside the code, while the intended behaviour is still fresh in your mind, and run the tests often enough that a failure surprises you early, when it is still cheap to fix.
Check: Why is a written, repeatable test more useful than just running a program once and eyeballing the output?
Section 2: Thinking Like a Tester
Thinking like a tester means asking, before you celebrate that something works, what could make it fail. A programmer naturally thinks about the happy path: the normal input, the expected steps, the correct result. A tester deliberately steps outside that path and asks about the odd input, the missing input, the largest input, the smallest input, and the input that looks valid but is not quite. This is not pessimism; it is a different kind of care. You are trying to protect the future user, who will not use your program as carefully as you do, and who will certainly paste in something you never imagined. The tester mindset is therefore a user mindset, adopted early, while the code is still easy to change.
A useful way to practise this mindset is to list cases before you write code. Take a simple function, such as one that returns the average of a list of numbers. The happy path is a list like four, six, and eight, whose average is six. But what about an empty list? What about a list with one number? What about negative numbers, very large numbers, or a list that contains something that is not a number at all? Each question is a potential test, and each answer is a design decision. Perhaps an empty list should raise an error, or return a special value, or be rejected by the caller. There is no single right answer for every program, but there must be an answer, and the test is where you record it. Without that decision, the program will decide for you, often by crashing at the worst moment.
Tester thinking also means separating what you hope from what you know. When you run a program and it prints the right answer once, you hope it is correct. When you run ten well-chosen cases and they all pass, you know something stronger, though still not everything. Good testers are comfortable with that partial knowledge and keep adding cases where doubt remains, rather than pretending one success settles the matter. They also pay attention to cases that pass for the wrong reason. A function that always returns zero will pass a test whose expected answer happens to be zero, and a careless tester might move on. A careful tester adds a second case with a different expected answer, so a lazy implementation cannot hide behind a coincidence.
Another part of the mindset is fairness to the code. Testing is not about trying to embarrass the program or prove that you are clever. The goal is to find real weaknesses before users do, and then to fix them. A test that is confusing, fragile, or tied to unimportant details does more harm than good, because it wastes time and teaches people to ignore failures. Aim for tests that are simple, focused, and clearly tied to behaviour a user would care about. If you cannot explain in one sentence why a test exists, it may be testing the wrong thing, or testing too many things at once. Keep that sentence in mind as you write, and let it guide what you include and what you leave out.
Check: What is the difference between testing the happy path and thinking like a tester?
Section 3: Arrange, Act, Assert
Most good tests follow a simple three-part rhythm known as arrange, act, and assert, and learning that rhythm makes tests easier to write and easier to read. In the arrange step, you set up everything the test needs: you create the input values, build the objects, and put the program into the starting state you want to examine. In the act step, you do the one thing you are testing, usually by calling a single function or method with the arranged inputs. In the assert step, you check the result against what you expected, using a plain comparison that either passes silently or fails with a clear message. Keeping these steps separate, even when a test is short, prevents a common beginner mistake: mixing setup, action, and checking together until nobody can tell what the test was really about.
Consider a function that calculates a discount. To test it, you arrange a price and a discount rate, you act by calling the function with those values, and you assert that the returned price matches the value you worked out by hand. If the test fails, the structure helps you find the problem quickly. Was the arrangement wrong, because you chose an input that does not mean what you thought? Was the action wrong, because you called the function with arguments in the wrong order? Or was the assertion wrong, because your hand calculation was mistaken? A test that blends everything into one long expression hides those questions, while a test with three visible steps invites them. Readability matters here because tests are read far more often than they are written, usually by someone trying to understand a failure under pressure.
The rhythm also helps you keep each test focused on one behaviour. If you find yourself acting twice, asserting five different things, or arranging a whole world of data, the test is probably trying to do too much. Split it. A test that checks one behaviour is easy to name, easy to fix when it fails, and easy to keep when the program changes. A test that checks five behaviours fails for five different reasons, and fixing it becomes a small project of its own. There are exceptions, such as checking several properties of one result, but the default should be one behaviour per test, with arrange, act, and assert each doing a single clear job.
Beginners sometimes worry that this structure is too formal for small programs, but formality is what makes small tests scale. When you have ten tests, structure is a convenience. When you have two hundred, it is a necessity, because it lets you scan a failing test and understand it in seconds. Practise the rhythm even on tiny examples, so that it becomes automatic before your programs grow large. Write the arrangement first, even if it is only one line. Call the function once. Assert once, clearly. Then run the test, watch it pass, and move on with a little more confidence than you had before.
Check: What are the three steps of arrange, act, and assert, and what does each step do?
Section 4: Naming Tests So Failures Make Sense
A test name is a small piece of documentation that only gets read when something goes wrong, which is exactly when clarity matters most. When a test suite runs, it usually prints the name of each test alongside a pass or fail mark, and a good name tells you immediately what behaviour broke, without forcing you to open the test file and read the code. A vague name like test_one or test_function tells you nothing, so a failure becomes a puzzle instead of a pointer. A descriptive name like test_discount_applies_ten_percent_to_price tells you the story at a glance: the discount behaviour, the rate involved, and the thing being tested. You may not know the cause yet, but you know where to look and what to check first.
Good test names usually mention the behaviour, the condition, and the expected outcome. For example, test_empty_list_returns_zero_average names the input condition and the expected result, while test_negative_quantity_raises_error names the condition and the kind of failure expected. You do not need to follow one rigid formula, but you do need enough detail that two different tests do not end up with nearly identical names. If you have test_average and test_average_two, the second name is a warning that you have not yet said what makes the second case different. Rename it to say what is different, such as test_average_of_single_value_returns_that_value, and the suite becomes self-explanatory. Long names are fine in tests; nobody judges a test by how short its name is, and a long clear name is far better than a short mysterious one.
Naming also helps you think before you write. If you struggle to name a test, it often means you are not sure what behaviour you are testing, or that the test is trying to cover too much. Pause and clarify the behaviour in plain words first, then turn those words into the name, then write the test to match. This habit prevents a common trap, where a test is written first and its purpose is invented afterwards, which usually produces tests that check incidental details rather than meaningful behaviour. A name written first acts as a promise: this test will check this behaviour, and nothing else, and when it passes, that behaviour is confirmed.
Finally, keep names honest when behaviour changes. If a function changes so that an empty list no longer returns zero but raises an error, update the test name as well as the test body, or the suite will lie about what it checks. Stale names are almost as misleading as stale documentation, because they send a reader looking in the wrong direction during a failure. Treat renaming a test as part of changing the behaviour it covers, not as optional tidying to be done later. With clear names, a failing test run reads like a short report on what broke, and fixing the program becomes a guided task rather than a hunt through anonymous checks.
Check: Why does a descriptive test name matter most at the moment a test fails?
Section 5: Your First Real Test
Your first real test should be as small as possible, because the goal is to learn the workflow, not to prove anything grand. Pick a function you already understand, such as one that doubles a number, adds two numbers, or returns the length of a word. Write the function in one file, and write the test in a separate file whose name begins with test, which is the usual convention that test tools use to discover tests. In the test file, import the function, call it with a known input, and assert that the result equals the value you expect. Then run the test tool from the terminal. Watching a tiny test pass is a surprisingly satisfying moment, and it teaches you the mechanics: where test files live, how they are found, how a pass is reported, and how little code a useful test actually needs.
The next step is to watch a test fail, deliberately. Change the expected value to something wrong, run the test again, and read the failure message carefully. A good failure message shows the expected value, the actual value, and the line where the assertion failed, which is usually enough to understand what happened. This exercise matters because beginners often fear failure messages, treating them as a sign that they have done something wrong, when in fact a failing test is the tool working correctly. It found a mismatch between expectation and reality, and it reported it plainly. Learn to read that report without panic, and you will fix bugs much faster, because you will start from evidence rather than from guessing and re-running the whole program.
After the deliberate failure, restore the correct expectation and add a second test for a different input. If the first test doubled three to get six, the second might double zero, or a negative number, or a large number. Each new test takes only a few lines, but together they build a picture of the function's behaviour across a range of inputs. Notice how little you needed to know about the test tool itself: import, call, assert, run. That simplicity is deliberate. The most widely used Python test tools are designed so that an ordinary function and an ordinary assertion become a test, without requiring you to learn a special language or inherit from a special class. The tool discovers your test functions, runs them, and reports the results, while you focus on choosing good examples.
Finally, make running tests a habit, not an event. Run them after every small change, not only when you think you are finished, because a failure found immediately after a change is easy to connect to that change, while a failure found hours later could have many causes. Keep the test file next to the code it tests, or in a clearly named tests folder, and keep the two in step as the program grows. Your first test is not a milestone to pass and forget; it is the start of a routine. Every program you write from now on deserves at least a few small tests, and the sooner you build that routine, the sooner it feels strange to write code without them.
Check: What should you deliberately do after watching your first test pass, and why?
Section 6: Expected Versus Actual
Every assertion is a comparison between two values: the value you expected and the value the program actually produced. Keeping those two roles straight is essential, because confusing them leads to tests that look correct but check the wrong thing, or failure messages that point you in the wrong direction. In most test code, you write the expected value yourself, from your own understanding of the problem, and the actual value comes from calling the function under test. If the two match, the test passes. If they differ, the test fails and shows you both values, so you can decide whether the program is wrong, the expectation is wrong, or the test itself was set up badly. That decision is the real work of testing, and it cannot be automated away.
A common beginner error is to compute the expected value by calling the very function being tested, or by copying the program's logic into the test. That makes the test pass almost by definition, because it checks the program against itself, and it will keep passing even if the logic is wrong. Expected values should come from an independent source: a hand calculation, a worked example from the problem statement, a value you verified another way, or a simple case whose answer is obvious. If you are testing a function that adds tax, work out one example with a calculator or on paper, and use that number. If you are testing a function that reverses a word, choose a word whose reversal you can see at a glance. Independence is what gives the test its power to catch mistakes.
Another useful habit is to make the failure message informative. When a comparison fails, the tool usually shows both values, but it helps to keep assertions simple so the message stays readable. Comparing two whole data structures can produce a long, confusing report, while comparing the specific value you care about produces a short, clear one. If a function returns a dictionary with many keys, and your test cares about one key, assert on that key rather than on the entire dictionary, unless the whole result is genuinely what you are checking. Similarly, avoid asserting on values that change every run, such as the current time or a random number, unless you have controlled them first. A test that sometimes passes and sometimes fails, for reasons unrelated to the code, teaches people to ignore failures, which defeats the purpose of testing.
Finally, remember that a mismatch does not automatically mean the program is at fault. Sometimes your expectation was wrong because you misunderstood the requirement, misread the problem, or made an arithmetic slip. Treat a failure as a question, not a verdict: which side is right, and how can you tell? Check the requirement again, work the example by hand again, and look at the function's code with fresh eyes. Often the answer becomes clear within a minute. That careful, evidence-based response to a mismatch is the skill that testing teaches, and it transfers to every other kind of debugging you will ever do.
Check: Why should an expected value never be computed by calling the function being tested?
Section 7: Edge Cases and Boundaries
Edge cases are the inputs that sit at the boundary of what a function is meant to handle, and they are where bugs most often live. Programmers naturally test the middle of the range, where everything behaves smoothly, but users and data do not stay in the middle. They send empty inputs, single items, the largest allowed value, one more than the largest allowed value, zero, negative numbers, and text with unusual characters. A function that works beautifully for typical inputs can fail badly at an edge, because the edge is where a loop runs zero times instead of many times, where a division meets zero, where an index reaches the end of a list, or where an assumption about length quietly breaks. Testing edges is therefore not extra credit; it is where testing earns its keep.
Boundary thinking gives you a systematic way to choose edge cases. For any rule with a limit, test just below the limit, exactly at the limit, and just above it. If a discount applies to orders over fifty, test forty-nine, fifty, and fifty-one, and make sure you know whether fifty itself qualifies, because that off-by-one detail is a classic source of bugs. If a function accepts a list, test the empty list, a list with one item, and a list with many items. If a function accepts text, test the empty string, a single character, and a long string. If a function accepts a number, test zero, a negative number, and a very large number. You do not need to test every possible value, but you do need to test the values where behaviour changes, because those are the values where a small coding slip changes the outcome.
Empty inputs deserve special attention, because they are both common and easy to forget. An empty list, an empty string, or an empty dictionary often arrives when a user has not entered anything yet, when a search returns no results, or when a file contains no records. A function that assumes at least one item exists will crash on an empty input, sometimes with a confusing error that hides the real problem. Decide deliberately what an empty input should mean for each function, write a test that states that decision, and make sure the function honours it. The same care applies to single-item inputs, which often expose mistakes in code that was written with several items in mind, such as a loop that skips the first or last element.
Writing edge tests also clarifies requirements. When you ask what should happen at a boundary, you may discover that nobody has decided yet, including you. That discovery is valuable, because it forces a decision before users encounter the ambiguity in real use. Record the decision in the test, using a name that states the boundary and the expected behaviour, so that future readers understand it was deliberate, not accidental. Over time, a collection of edge tests becomes a precise description of a function's contract: what it accepts, what it rejects, and what it returns at every important boundary. That contract is worth more than a vague paragraph of documentation, because it is checked every time the tests run.
Check: What does boundary thinking suggest you test for a rule with a limit, and why?
Section 8: Testing Many Inputs Without Repeating Yourself
When you need to test the same behaviour with many different inputs, writing a separate test function for each input quickly becomes repetitive and hard to maintain. Each copy looks almost identical, differing only in the input and the expected result, and when the behaviour changes, you must update every copy. The solution is to express the pattern once and let the test tool run it many times with different values, an approach often called parameterisation. You provide a list of input and expected-output pairs, and the tool generates a separate test case for each pair, reporting each one individually. If one pair fails, you know exactly which input caused the problem, without wading through duplicated code.
Parameterisation is valuable because it encourages you to think in examples rather than in single cases. Instead of testing one discount calculation, you list several: a small price, a large price, a zero price, a price at the discount threshold, and a price just above it. Each example is a row in a small table of cases, and reading that table gives a reader a quick sense of the function's behaviour across its range. Adding a new case becomes a one-line change: add another row to the table, rather than copying a whole test function and risking a mistake in the copy. This also makes it easier to include edge cases, because the cost of adding one more row is tiny compared with the cost of writing another full test.
There are limits to this approach, and recognising them keeps your tests readable. Parameterisation works best when every case follows the same steps: arrange the same way, act the same way, and assert the same way, differing only in the data. If different cases need different setup or different kinds of assertion, forcing them into one parameterised test makes the test harder to understand than separate tests would be. In that situation, write separate tests, or split the parameterised test into two groups with different structures. Clarity beats cleverness. A test suite that is easy to read will be maintained; a test suite that is clever but confusing will be avoided, and avoided tests quickly become stale.
A related habit is to keep the table of cases close to the test and clearly labelled, so that a reader can see the inputs and expected outputs without hunting through other files. Name the parameters meaningfully, and choose values that illustrate the behaviour, not random numbers that happen to pass. A table with inputs like zero, one, ten, and one hundred tells a story about scale; a table with inputs like seventeen, forty-three, and ninety-one tells no story at all, even if the arithmetic is correct. Good test data is chosen, not accidental. When you combine parameterisation with thoughtful data, you get a compact, expressive suite that covers a wide range of behaviour in very little code, and that is exactly the kind of suite that survives as a program grows.
Check: What problem does parameterisation solve, and when should you avoid forcing different cases into one parameterised test?
Section 9: Setup and Fixtures
As tests grow, many of them need the same starting point: the same sample data, the same object in a known state, or the same temporary file with known contents. Copying that setup into every test is tedious and fragile, because a change to the setup must then be made in many places, and one forgotten copy will produce a confusing failure. A fixture is the standard answer to this problem: a named piece of setup code that a test can request by name, receiving a fresh, ready-made starting point without duplicating the setup logic. The test declares what it needs, the fixture provides it, and the test itself stays focused on the behaviour it checks. This separation keeps tests short and makes the shared setup easy to change in one place.
Freshness matters more than it first appears. A fixture should normally give each test its own copy of the setup, so that one test cannot accidentally affect another by modifying shared data. Imagine two tests that both use the same list, and the first test appends an item to it. If the second test receives the same list, it will see an extra item it did not expect, and it may fail for a reason that has nothing to do with the code being tested. That kind of interference produces failures that appear and disappear depending on the order in which tests run, which is deeply confusing. A good fixture builds the data anew for each test, or gives each test a copy, so that tests remain independent even when they share a setup pattern. Independence is a property worth protecting, because it makes failures reproducible: a test that fails in the full suite should also fail when run on its own.
Fixtures can also handle cleanup, which is the work of undoing setup after a test finishes, such as removing a temporary file or closing a connection. In simple programs, cleanup may be unnecessary, because the setup is just data in memory that disappears when the test ends. In programs that touch files or other resources, cleanup matters, because leftover files can affect later tests or clutter the workspace. A fixture that provides a temporary file and removes it afterwards keeps the test environment tidy without requiring each test to remember the cleanup step. Learners should start with simple fixtures for data and objects, and add cleanup only when the tests genuinely create resources that persist beyond the test.
Use fixtures sparingly, and only for setup that is genuinely shared. If only one test needs a particular piece of data, put that data in the test itself, where a reader can see it without jumping to another definition. A fixture used by a single test adds indirection without benefit. If three or more tests share the same setup, a fixture starts to pay for itself in reduced duplication and easier maintenance. Name fixtures clearly, keep them simple, and resist the urge to build elaborate fixture hierarchies that are harder to understand than the tests they serve. The goal is tests that a newcomer can read from top to bottom, with shared setup visible but not overwhelming.
Check: Why should a fixture normally give each test a fresh copy of its setup rather than sharing one object?
Section 10: Testing That Errors Happen
Not every correct behaviour is a successful return value. Sometimes the correct behaviour is to raise an error, because the input is invalid, a required value is missing, or an operation cannot be completed safely. Testing that an error happens is just as important as testing successful results, because an error that should happen but does not can allow bad data to flow deeper into a program, where it causes a stranger failure later. Conversely, an error that happens when it should not can block legitimate work. A good test suite therefore includes tests that deliberately feed invalid inputs to a function and assert that the expected kind of error is raised, rather than assuming that error handling works because it was written.
The structure of an error test is slightly different from a success test, but the arrange, act, and assert rhythm still applies. You arrange the invalid input, you act by calling the function inside a construct that captures the error instead of letting it crash the test, and you assert that the right kind of error was raised. The kind matters. Asserting that any error occurred is weak, because it would pass even if the function raised a completely unrelated error for a different reason. Asserting the specific error type, such as a value error for an invalid value or a key error for a missing key, ties the test to the behaviour you actually specified. If the function raises the wrong kind of error, the test should fail, because the caller may be handling one kind of error and not the other, and a mismatch could mean the caller's handling never triggers.
Error tests also force you to decide which inputs are invalid and how the program should respond to each. Is a negative age invalid, or just unusual? Is an empty name invalid, or acceptable? Is a missing file an error, or a signal to create a new one? These are design decisions, and they belong in tests because they record the decisions in runnable form. A test that says a negative quantity raises a value error documents the rule far more precisely than a comment that says quantities should be positive. When requirements change, updating the error test and its name keeps the rule visible, and anyone who later wonders why a particular input is rejected can find the answer in the test suite.
Finally, test errors at the boundary between valid and invalid, not only for wildly wrong inputs. A wildly wrong input, such as passing text where a number is expected, may raise an error for reasons that have little to do with your function's own validation. A boundary input, such as zero where only positive numbers are allowed, or a value just beyond a limit, tests your function's own checks. Combine a few wild cases with several boundary cases, and you will have a realistic picture of how the function defends itself. Error handling is part of a function's contract with its callers, and testing it is part of honouring that contract.
Check: Why should an error test assert the specific type of error, rather than just that some error occurred?
Section 11: Testing String Functions
String functions are a good place to practise testing, because their behaviour is easy to see and their edge cases are easy to imagine. A function that tidies a name, formats a greeting, or counts words in a sentence can be tested with short, readable examples whose expected results you can verify at a glance. Start with ordinary inputs: a name with normal capitalisation, a sentence with single spaces, a word in lower case. Then move to the cases that reveal assumptions: a name with extra spaces at the start or end, a name with double spaces inside, an empty string, a string made only of spaces, a string with mixed capitalisation, and a string containing numbers or punctuation. Each of these cases asks the function a slightly different question, and together they describe what tidy or formatted actually means for your program.
One trap with string tests is asserting on formatting details that do not matter to the user. If a greeting function returns a message, the important behaviour is usually that the name appears correctly and the wording is right, not the exact number of spaces in an internal template that nobody sees. Overly strict assertions make tests brittle: a harmless improvement to wording breaks tests even though the program still works well. Focus assertions on the behaviour that matters, such as the cleaned name, the word count, or the presence of a required part of the message, and leave cosmetic details alone unless they are part of the specification. When wording itself is the specification, such as an error message shown to a user, then exactness is appropriate, and the test should say so plainly.
Case sensitivity is another rich source of string bugs. A function that checks whether a word is present in a sentence may work when the cases match and fail when they do not, and a function that sorts names may place capitalised names before lower-case ones in a way that surprises users. Decide deliberately whether comparisons should be case-sensitive or case-insensitive, and write tests that state the decision. A test with an upper-case input and a lower-case expected match documents a case-insensitive rule, while a test that expects no match for different cases documents a case-sensitive rule. Without such tests, the rule is whatever the code happens to do, and a later change may flip it without anyone noticing.
Whitespace deserves its own tests, because it is invisible in output and therefore easy to overlook. A string that looks empty may contain spaces, tabs, or newlines, and a function that checks for emptiness by comparing with an empty string will treat such a string as non-empty. Test with strings of spaces, with leading and trailing whitespace, and with tabs where relevant, and decide whether your function should strip whitespace before checking. Similarly, test strings with characters from other languages and with apostrophes, hyphens, and other punctuation that appear in real names. A string function tested only on simple English words may fail on the first real name it meets, and a few thoughtful tests now prevent an embarrassing failure later.
Check: Why should string tests include empty strings, strings of only spaces, and mixed-case inputs, not just ordinary words?
Section 12: Testing Lists and Dictionaries
Lists and dictionaries carry collections of data, so testing functions that use them means checking both individual values and the shape of the whole collection. A function that filters a list, for example, should be tested on a list where some items pass and some fail, on a list where all items pass, on a list where none pass, and on an empty list. Each case checks a different aspect of the filtering logic. The mixed case checks that the filter distinguishes correctly, the all-pass case checks that nothing is wrongly removed, the none-pass case checks that the result is empty rather than an error, and the empty case checks that the function handles having nothing to filter. A single test with a mixed list would miss several of these distinctions, which is why collection tests usually need a small family of cases.
Order matters in many list functions, and tests should make order expectations explicit. A function that sorts, reverses, or returns the first few items of a list has an order contract, and a test that compares the whole resulting list in order checks that contract precisely. Be careful, however, not to demand order where none is promised. A function that returns a set of unique values, or a collection whose order is not specified, should be tested by comparing sorted results or by checking membership, rather than by asserting an order that the function never guaranteed. Demanding unspecified order makes tests brittle, because a correct implementation that returns items in a different order will fail for no good reason. Read the function's purpose and test what it promises, not what one particular implementation happens to do.
Dictionaries raise similar questions about keys. Test a function that looks up a value with a key that exists, a key that does not exist, and an empty dictionary. Decide what should happen for a missing key: should the function return a default, raise an error, or return nothing? Write a test for that decision, because callers depend on it. Also test dictionaries with keys of the expected type and, where relevant, with unexpected but plausible keys, such as a number where a string was expected, if your function should handle or reject them. Dictionary tests should also check that the original dictionary is unchanged when a function should return a new one, because accidental modification is a bug only a before-and-after comparison will catch.
Mutation is the deepest trap in collection testing. Many list and dictionary operations change the collection in place, while others return a new collection and leave the original alone, and confusing the two produces bugs that are hard to spot by reading code. A test can distinguish them by arranging a known input, calling the function, and then asserting both on the returned value and on the original collection. If the function is meant to leave the input untouched, assert that the input still equals its original value. If it is meant to modify the input, assert the new state of the input and, where relevant, that the returned value is what you expect. Making mutation explicit in tests prevents a whole class of silent data corruption, where a function works correctly once but damages the data for every later use.
Check: How can a test tell whether a function modifies its list input in place or returns a new list?
Section 13: Testing Numbers and Boundaries
Number functions look simple, which makes them deceptively easy to under-test. A function that calculates a total, an average, a percentage, or a discount involves arithmetic that can go wrong in subtle ways: integer division where float division was intended, rounding at the wrong step, overflow in languages with fixed-size numbers, or a formula that works for positive values but not for negative ones. In Python, integers can grow large without overflowing in the usual sense, but division, rounding, and floating-point representation still create surprises. A test with a hand-calculated example is the simplest defence, and several examples covering different magnitudes are much stronger than one example repeated with different names.
Rounding deserves deliberate tests, because users notice money most. If a function calculates a price, decide how many decimal places the result should have and how rounding should work, then test values that sit exactly on a rounding boundary. For example, a value that should round up and a value that should round down, as well as a value that needs no rounding at all, together show that the rounding rule is applied consistently. Be careful with floating-point equality: two values that look equal when printed may differ in the last decimal place, because binary floating-point cannot represent some decimal fractions exactly. When testing float results, it is often wiser to check that the result is close to the expected value within a tiny tolerance, rather than exactly equal, unless the function itself rounds to a fixed number of decimal places. A test that demands exact equality for an unrounded float will sometimes fail for reasons that have nothing to do with your logic.
Negative numbers and zero are essential number tests. A function that calculates a discount should be tested with a zero price, a zero rate, and, if negative values are possible inputs, a negative price or rate, with a clear decision about whether negatives are rejected or handled. A function that divides should be tested with a zero divisor, because division by zero raises an error in Python, and you need to decide whether that error is acceptable or whether the function should handle the case gracefully. A function that calculates a percentage should be tested with a total of zero, which is another division-by-zero trap. These cases are not exotic; they arise whenever a user enters nothing, a quantity runs out, or a total has not yet been set, and a program that crashes on them feels broken even if its main calculations are correct.
Finally, test numbers at realistic extremes, not just small textbook values. A total with many items, a price with large digits, and a quantity in the thousands can expose mistakes in formatting, in accumulation over a loop, and in assumptions about size. You do not need to test absurdly large values unless the program genuinely faces them, but you should test values at the top of the realistic range, because that is where a loop that works for three items might still fail for three thousand, through slowness rather than wrongness, or through an assumption that breaks at scale. Number tests are cheap to write and rich in information, so write more of them than you think you need, and keep the hand-calculated expectations independent of the code.
Check: Why is exact equality sometimes the wrong way to compare floating-point results in a test?
Section 14: Testing Conditions and Decisions
Functions that make decisions, using conditions to choose between different paths, need tests that exercise every path, not just the most common one. If a function returns one message for adults and another for children, based on an age threshold, then a test with only an adult age leaves the children's path untested, and a bug in that path could survive for a long time. The discipline is simple: for each condition in a function, write at least one test where the condition is true and one where it is false. For a chain of conditions, such as grading boundaries or shipping tiers, write a test for each branch, plus tests at the boundaries between branches, because the boundary is where one branch hands over to the next and where mistakes are most likely.
Compound conditions need extra care. A rule that depends on two conditions at once, such as a discount that applies only when a customer is a member and the order is above a threshold, has four combinations: both true, both false, and each one true while the other is false. Testing only the both-true and both-false cases leaves two combinations unexamined, and a mistake in how the conditions are combined, such as using or where and was intended, can hide in exactly those untested combinations. You do not need to test every combination for every function, but for rules that matter, such as pricing, eligibility, or access, working through the combination table is worth the effort. Each combination becomes a small, clearly named test, and the set of tests together documents the rule more precisely than a paragraph of prose.
Decision tests also help you notice conditions that can never happen. If you try to write a test for a branch and discover that no input can reach it, because an earlier condition already covers those inputs, then the branch is dead code, and it should be removed or reconsidered. Dead branches confuse readers, who wonder what case they handle, and they can hide misunderstandings about the logic. Similarly, if two branches produce the same result for every input you can imagine, the distinction between them may be unnecessary. Testing forces you to confront the logic as it actually behaves, rather than as you imagined it when you wrote it, and that confrontation often leads to simpler, clearer code.
Another useful practice is to test decision functions with inputs that are just on either side of a threshold, because thresholds are where human language is ambiguous. Does over eighteen mean eighteen itself counts as adult, or only nineteen and above? Does a discount for orders over fifty include fifty itself? Natural language often leaves this unclear, and code must choose. A pair of tests, one at the threshold and one just below or above it, records the choice unambiguously. When a future reader wonders what the rule is, the tests answer the question without ambiguity, and when a future change alters the threshold, the tests that break will point directly at the decision that changed. That is testing at its most valuable: not just catching bugs, but preserving decisions.
Check: For a rule that depends on two conditions, why is it not enough to test only the both-true and both-false combinations?
Section 15: Refactoring Safely with Tests
Refactoring means changing the internal structure of code without changing its behaviour, and tests are what make that promise checkable. Without tests, refactoring is a matter of faith: you tidy the code, run the program once, see a familiar result, and hope nothing else changed. With tests, refactoring becomes a verifiable activity: you run the tests before the change to confirm they pass, you make the change, and you run the tests again to confirm they still pass. If they do, you have evidence that the behaviour is preserved. If they do not, you have a precise report of what changed, and you can undo or correct the refactoring before the damage spreads. That loop is the reason experienced developers write tests before they tidy code, not after.
Safe refactoring follows a small, disciplined rhythm. Make one change at a time, and run the tests after each change, rather than making ten changes and then discovering a failure that could have any of ten causes. The changes can be modest: renaming a variable for clarity, extracting a repeated block into a helper function, simplifying a condition, or reorganising a function into smaller functions. Each change should be behaviour-preserving, and the tests are the check. If a change requires updating a test, pause and ask why. A refactoring that changes behaviour is not a refactoring; it is a behaviour change, and it should be treated as one, with its own tests and its own justification. Tests that need updating during a supposed refactoring are a warning that the change is larger than you thought.
Test quality determines how much protection refactoring actually gives. A test suite that only checks the happy path will happily pass after a refactoring that breaks an edge case, giving false confidence. A suite that covers edges, errors, and boundaries will catch such breakage, because the broken behaviour is actually checked. This is why coverage of important behaviour matters more than the sheer number of tests. Before a significant refactoring, it is worth adding tests for any behaviour you are unsure about, so that the refactoring is checked against the behaviour you care about, not just the behaviour you happened to test earlier. Think of it as strengthening the net before you walk across it.
Refactoring with tests also changes team dynamics. In a team, a developer can improve shared code without fear of silently breaking a colleague's feature, because the colleague's tests will fail if the improvement changes their feature's behaviour. That safety encourages continuous tidying, which keeps a codebase healthy over time, instead of allowing it to decay until a large, risky rewrite feels necessary. For a learner working alone, the same principle applies on a smaller scale: your future self is the colleague whose work you must not break, and tests are the message your present self leaves to protect that future work. Refactor in small steps, run the tests often, and let the green results give you the confidence to keep improving.
Check: Why should you run tests after each small refactoring step, rather than after many changes at once?
Section 16: Red, Green, Refactor
Red, green, refactor is a rhythm for developing with tests, and its name describes three phases that repeat in a short cycle. In the red phase, you write a test for a behaviour that does not exist yet, and you watch it fail, which is the red signal. The failure is expected and valuable, because it proves the test actually checks something and that the behaviour is genuinely missing. In the green phase, you write the simplest code that makes the test pass, without worrying about elegance, because the goal is a working behaviour, not a beautiful implementation. In the refactor phase, you tidy the code you just wrote, improving its structure while keeping the behaviour, and you run the test again to confirm it still passes. Then the cycle repeats with the next behaviour.
The red phase is the most counterintuitive part for beginners, because it asks you to write a failing test on purpose. Its value becomes clear when you skip it. If you write the code first and the test afterwards, the test may pass for the wrong reason, or it may test something slightly different from what you intended, and you will never know, because you never saw it fail. Watching a test fail first confirms that it is sensitive to the behaviour: it fails when the behaviour is absent, so its later passing means something. A test you have never seen fail is a test you have not yet proven. That proof takes only a moment, and it prevents a class of false confidence that is otherwise hard to detect.
The green phase teaches restraint. When the test is failing, the temptation is to build a complete, general solution at once, anticipating every future need. Red, green, refactor asks you to resist that temptation and to write just enough code to pass the current test, because a simple solution is easier to understand, easier to test, and easier to extend later when a new test demands more. This does not mean writing sloppy code; it means writing the smallest honest solution for the behaviour at hand. If the next test reveals that the solution is too narrow, you extend it then, guided by a concrete failing test rather than by speculation about what might be needed someday. Speculation produces complexity; failing tests produce exactly the complexity that is needed.
The refactor phase is where design happens, but it happens under the protection of passing tests. You can rename, extract helpers, simplify conditions, and remove duplication, knowing that the tests will catch any accidental behaviour change. Over many cycles, this rhythm produces code that grows in small, verified steps, rather than in large, unverified leaps. It also produces a test suite that grows alongside the code, documenting each behaviour as it is added. Learners sometimes find the rhythm slow at first, because it involves writing tests before code, but the slowness is an investment. Debugging a large, untested program is far slower than building it in small, tested steps, and the rhythm pays for itself the first time it catches a mistake early.
Check: Why is it important to watch a new test fail before you write the code that makes it pass?
Section 17: Test Independence
Test independence means that each test can run on its own, in any order, and produce the same result, regardless of which other tests ran before it. This property sounds technical, but it matters in very practical ways. When tests are independent, a failure points at the code being tested, not at the test that ran before it. When tests depend on each other, a failure may mean that an earlier test did not set something up correctly, or that tests were run in a different order than expected, and debugging becomes a hunt through the whole suite rather than a look at one test. Independence also lets you run a single test in isolation while debugging, which is one of the most useful workflows in testing, and it only works if the test does not secretly rely on another test having run first.
Dependence usually creeps in through shared mutable state. A test that modifies a global variable, appends to a shared list, writes to a shared file, or changes a setting and does not restore it leaves the world different from how it found it, and the next test inherits that difference. The fix is to make each test set up its own state and clean up after itself, or to use fixtures that provide fresh state for each test, as discussed earlier. A simple way to check for dependence is to run tests in a different order, or to run a failing test on its own. If a test passes alone but fails in the full suite, or passes in one order but fails in another, dependence is the likely culprit, and the investigation should focus on what shared state changed between runs.
Another form of dependence is ordering through files or databases. If one test creates a record and another test assumes that record exists, the second test cannot run alone, and the suite becomes fragile whenever tests are added, removed, or reordered. Each test should create the data it needs, even if that means a little duplication, because duplication in test setup is far cheaper than the confusion of hidden ordering. The same principle applies to time: a test that assumes a certain date or a file created at a certain time depends on the world outside the test, and it will fail when that world changes. Control the inputs within the test, or provide them through a fixture, so that the test's outcome depends on the code, not on the environment.
Independence also makes test suites easier to run in parallel, which matters as suites grow large. Tools can run independent tests at the same time on different processors, dramatically reducing the time a full suite takes, but only if tests do not interfere with each other through shared files or shared state. Even for a learner with a small suite, independence is a habit worth building early, because it keeps failures honest and debugging straightforward. A good rule of thumb is that you should be able to run any single test, at any time, and trust its result. If you cannot, the test needs more setup, better isolation, or a fixture that gives it a clean starting point.
Check: What does it mean for a test to be independent, and why does a test that passes alone but fails in a suite suggest dependence?
Section 18: Small Tests, Clear Failures
Small tests fail clearly, and clear failures are fixed quickly. A test that checks one behaviour produces a failure message that points at one behaviour, while a test that checks many behaviours produces a failure that could mean many things, and the developer must investigate before fixing. That investigation time adds up, especially in a large suite, where a developer may face several failures at once and need to triage them quickly. A suite of small tests acts like a set of precise instruments, each measuring one thing, while a suite of large tests acts like a single alarm that tells you something is wrong somewhere, without saying where. Precision is worth the extra test functions, because it converts debugging from searching into reading.
Smallness also makes tests easier to name, easier to understand, and easier to keep when the code changes. A test that checks one behaviour has an obvious name, an obvious purpose, and an obvious fix when it fails. A test that checks five behaviours has a name that must summarise five things, a body that mixes five setups, and a failure that requires unpicking which of the five broke. When the code changes in a way that affects only one of those behaviours, the large test must be rewritten in part, while the small tests can be updated one at a time. Over time, large tests accumulate compromises, because each change touches a test that does several jobs, and the test becomes harder to reason about with every edit. Small tests resist that decay, because each edit touches a test with a single job.
There is a balance to strike, because tests that are too small can become noisy. A suite with hundreds of trivial tests, each checking a detail that nobody cares about, is hard to maintain and slow to read, even if each test is clear. The goal is not the smallest possible test, but the smallest test that checks a meaningful behaviour. A behaviour is meaningful if a user, a caller, or a future maintainer would care whether it works. Checking that a discount function returns the right price for a given input is meaningful. Checking that an internal helper returns a value in a particular format that no caller sees is usually not, unless that format is part of a contract. Let meaningfulness, not size alone, guide how you split tests, and split a test whenever you notice it checking two behaviours that could fail independently.
A practical way to keep tests small is to notice the word and in a test's purpose. If you describe a test as checking that a function does this and that, it is probably two tests. Split it, name each part, and let each test have its own arrange, act, and assert. The extra lines of code are a small price for failures that explain themselves. When you run a suite and see a failure, you should be able to say, from the test name alone, what behaviour broke and where to look. If you cannot, the test is too large, too vague, or both, and splitting it is the fix.
Check: Why does a test that checks many behaviours produce a less useful failure than a test that checks one?
Section 19: Readable Assertions
An assertion is a sentence about what the program should do, and like any sentence, it should be easy to read. Readable assertions compare the actual result with the expected result directly, using the simplest comparison that expresses the requirement. A test that asserts a function returns six is readable. A test that asserts a complicated expression involving several function calls, arithmetic, and conversions is not, because a reader must evaluate the expression mentally before understanding what is being checked, and a failure message for such an expression is often hard to interpret. Keep the assertion itself simple, and move any complicated preparation into the arrange step, where it can be named and understood separately.
Descriptive failure messages help, but they are a supplement, not a substitute for a clear assertion. Some test tools let you add a message that appears when an assertion fails, explaining what was expected and why it matters. Use such messages sparingly, for cases where the bare comparison would be confusing, such as when the values being compared are not self-explanatory. A message that repeats the assertion adds noise, while a message that explains the business rule behind the assertion adds understanding. For example, a message noting that a discount should not exceed the price itself explains why a particular assertion exists, in a way that the numbers alone do not. Do not overuse messages, or they become another thing to maintain, but do use them where they genuinely clarify intent.
Assertion style also affects how failures are reported. Comparing whole objects, such as two dictionaries or two lists, can produce a long difference report that highlights exactly where they differ, which is useful when the whole object matters. Comparing a single attribute or element produces a short report that is easier to read when only one part matters. Choose the level of comparison that matches the behaviour you are checking. If you care about the entire result, compare the entire result. If you care about one field, compare that field. This choice is part of test design, and it signals to future readers what the test considers important. A test that compares everything when only one thing matters will fail for irrelevant changes, while a test that compares too little will pass when something important breaks.
Finally, avoid assertions that are true by construction, such as asserting that a value equals itself, or that a function returns something that is not nothing, when the real requirement is a specific value. Weak assertions give false confidence, because they pass even when the function is badly wrong, and they make the suite look more thorough than it is. Every assertion should be strong enough to fail if the behaviour it checks is broken. A useful check is to imagine breaking the function deliberately: would this assertion catch it? If the answer is no, strengthen the assertion, add another one, or reconsider what the test is really checking. Readable, strong assertions are the core of a trustworthy suite.
Check: How can you tell whether an assertion is too weak to catch a real bug in the function it tests?
Section 20: Helpers for Tests
As a test suite grows, you will notice repeated patterns: the same kind of sample data built in several tests, the same sequence of calls to set up a scenario, or the same checks performed on a result. Test helpers are small functions, written for the tests themselves, that capture those patterns in one place, so that tests stay short and readable. A helper might build a sample customer, create a list of test orders, or check that a result has the expected shape. Helpers are not part of the program being tested; they live with the tests, and their job is to make tests easier to write and easier to read. Used well, they reduce duplication without hiding what a test is doing.
Helpers need the same care as any other code, because a bug in a helper can make many tests pass or fail incorrectly. Keep helpers simple, name them clearly, and avoid building helpers that are so clever they become hard to understand. A helper that builds a sample object with sensible defaults, allowing a test to override one or two fields, is a good example: it is easy to understand, easy to use, and it makes each test's distinctive data stand out, because the test only mentions what is different from the default. A helper that builds an entire scenario with hidden assumptions, on the other hand, makes tests mysterious, because a reader cannot tell what the test is actually checking without reading the helper in detail. The test should still read as a story about behaviour, with the helper providing background, not the plot.
A related idea is the custom check: a helper that asserts several related things about a result, such as that a report contains the right totals, the right headings, and the right number of rows. Custom checks can make tests more expressive, but they also hide detail, because a failure inside a helper may be reported at the helper's line rather than at the test's line, making it harder to see which test failed and why. Use custom checks for genuinely repeated assertions that are always checked together, and keep them small and well-named, so that a failure message still makes sense. If a custom check is used by only one test, inline the assertions in that test instead, where they are visible without indirection.
Finally, remember that helpers are a means, not an end. The goal is a test suite that a newcomer can read and trust, not a suite with the fewest lines of code. If a helper saves lines but costs understanding, it is a bad trade. Review helpers periodically, especially when tests start failing in confusing ways, and ask whether the helper is still helping or whether it has become a layer of mystery. Good helpers disappear into the background, letting the test's behaviour shine through. Bad helpers demand attention for themselves, and they should be simplified or removed. Keep helpers boring, obvious, and small, and your tests will stay readable as they grow.
Check: What makes a test helper good, and when does a helper start to harm test readability?
Section 21: Testing Functions That Read Files
Functions that read files need tests that control the file contents, because a test that depends on a real file in the workspace depends on the world outside the test. If the file is edited, moved, or deleted, the test may fail for reasons unrelated to the code, and if the test runs on another computer, the file may not exist at all. The solution is to give each test its own temporary file with known contents, created as part of the test's arrangement, so that the test is self-contained. The function reads from that temporary file, the test asserts on the result, and the temporary file is removed afterwards, leaving no trace. Self-contained tests are portable, repeatable, and honest about what they check.
Temporary files are usually provided by the test tool or by the standard library, which can create a file in a temporary directory, hand you its path, and clean it up when the test finishes. Using that mechanism is better than creating files with fixed names in the project folder, because fixed names can collide between tests, or between a test run and a user's real files. A test that writes to a file called data.txt in the current folder might overwrite something important, or might be affected by a leftover file from a previous run. A temporary file, by contrast, lives in a place meant for short-lived data, with a unique name, and it disappears when it is no longer needed. That isolation is exactly what tests need, and it is worth learning the mechanism early, even for simple file tests.
When testing file-reading functions, think about the same edge cases that matter for other inputs, translated into file terms. Test an empty file, a file with one line, a file with many lines, a file with a trailing newline, a file with blank lines in the middle, and a file with unexpected whitespace. Each case checks how the function handles the shape of real file data, which is often messier than textbook examples. Also test a file that does not exist, if your function is supposed to handle that case, and decide whether it should raise an error, return an empty result, or create the file. Record that decision in a test, because callers need to know what to expect when the file is missing, and a test is the clearest place to record it.
Another important distinction is between testing the function and testing the file system. Your test should check that the function correctly interprets file contents, not that the operating system can read files, which is already well tested by others. Keep the file contents small and focused on the behaviour you are checking, such as parsing lines, skipping headers, or handling separators. If the function also writes files, test the written contents by reading them back in the test and comparing with the expected text, again using a temporary file. File tests that are small, self-contained, and focused on interpretation will catch real bugs in parsing and handling, without depending on the state of any particular computer.
Check: Why should a file-reading test use a temporary file with known contents instead of a real file in the project?
Section 22: Testing with Temporary Data
Temporary data is a generalisation of temporary files: any data that a test creates for its own use and discards afterwards, so that tests do not interfere with each other or with real data. This includes temporary directories, temporary lists of records, and sample data built fresh for each test. The principle is the same as for fixtures: each test should start from a known, clean state, and should leave the world as it found it. When tests follow this principle, they can run in any order, on any computer, without depending on files or data left behind by previous runs. When they do not, failures become mysterious, because they depend on history rather than on the code.
A common mistake is to test against a real data file that the program also uses in normal operation. At first this seems convenient, because the data already exists, but it creates a hidden coupling: changes to the real data change the test results, and changes made by tests can corrupt the real data. A test that appends a record to a real data file, for example, will change the file for the next run, and the test may pass the first time and fail the second, because the record already exists. Temporary data avoids this trap entirely. The test creates its own data, checks the behaviour, and discards the data, so that repeated runs give identical results. Repeatability is not a luxury; it is what makes a test result trustworthy.
Temporary data also lets you test scenarios that would be awkward or risky with real data. You can create a data set with a thousand records to check performance or handling at scale, a data set with duplicate records to check deduplication, or a data set with deliberately malformed records to check error handling, all without touching real user data. You can also create data that represents edge cases rarely seen in real use, such as an empty data set, a data set with one record, or a data set where every record is identical. Real data may never contain these cases, but a program should still handle them correctly, and temporary data makes it easy to check that it does. The ability to invent precise scenarios on demand is one of the great strengths of testing.
Clean-up is part of the discipline. In many test tools, temporary data is removed automatically when the test finishes, but when you create data manually, you should remove it yourself, or use a mechanism that guarantees removal even if the test fails. A test that fails midway should not leave behind files or records that affect the next test, because then a single failure can cascade into many, hiding the original problem. Think of each test as a guest that should leave the room tidy. With temporary data, clean setup, and reliable clean-up, your tests become a safe laboratory: a place to try scenarios, including destructive ones, without any risk to real work.
Check: What goes wrong when a test writes to a real data file instead of using temporary data?
Section 23: Fakes, Stubs and Doubles
Sometimes a function depends on something that is slow, unreliable, or hard to control in a test, such as a network service, a clock, a random number generator, or a database. Testing such a function directly would make the test slow, flaky, or dependent on the outside world, which defeats the purpose of a quick, repeatable test. A test double is a stand-in for that dependency: a simple object or function that behaves like the real thing for the purposes of the test, but is fast, predictable, and under your control. Different kinds of doubles have different jobs. A stub returns a fixed answer, so you can test how your function handles that answer. A fake provides a simplified working implementation, such as an in-memory store that behaves like a database for basic operations. A mock records how it was called, so you can assert that your function called it correctly.
The key idea is to test your function's logic, not the dependency's behaviour. If your function fetches a price from a service and then applies a discount, the interesting logic is the discount calculation and the handling of the fetched price, not the network fetch itself. A stub that returns a known price lets you test that logic quickly and reliably, without waiting for a network and without depending on the service being available. Similarly, if your function sends a message through a service, a double that records the message lets you assert that the right message was sent, without actually sending anything. Doubles separate the logic you wrote from the services you use, and that separation makes tests faster and more focused.
Doubles must be used with care, because they can hide real problems. A stub that returns a price in a different format from the real service will make your tests pass while the real program fails, because the real service's format was never tested. A fake that behaves slightly differently from the real database can mask a bug that only appears with the real system. For this reason, doubles are best for unit tests of logic, while a smaller number of integration tests should still exercise the real dependency, even if those tests are slower and run less often. The combination gives you both speed and realism: fast unit tests with doubles for everyday development, and slower integration tests with real services for confidence that the pieces fit together.
Designing code for testability helps here. A function that calls a service directly, with no way to substitute a double, is hard to test in isolation. A function that receives its dependency as a parameter, or that calls a small, replaceable helper, is easy to test, because the test can pass a double in place of the real service. This design habit, sometimes called dependency injection, is worth learning early, because it makes code more flexible as well as more testable. When you write a function that depends on something external, ask yourself how a test would replace that dependency with a double. If the answer is not obvious, consider restructuring the function so that the dependency is passed in, and both testing and future changes will become easier.
Check: What is a test double, and why should it not completely replace tests against the real dependency?
Section 24: Testing Time-Dependent Code
Code that depends on the current time is notoriously hard to test, because the answer changes every moment. A function that says good morning or good evening depending on the hour, or that calculates an age from a birth date and today's date, will give a different result depending on when the test runs. A test written at ten in the morning may pass then and fail in the evening, not because the code broke, but because the time changed. Such tests are flaky, and flaky tests teach people to ignore failures, which is dangerous. The solution is to control time in tests, by passing the time in as a parameter, or by replacing the clock with a double that returns a fixed time.
Passing time as a parameter is the simplest and most honest approach. Instead of a function that reads the current time internally, write a function that accepts a time or a date as an argument, and let the caller supply the current time in normal use. In tests, you can then pass any time you like: morning, evening, midnight, a leap day, or New Year's Eve, and assert the expected behaviour for each. This design makes the function easier to test, easier to reason about, and easier to reuse, because it does not secretly depend on the global clock. The caller remains responsible for providing the current time, but the logic itself becomes pure and predictable, which is exactly what you want for a function that makes decisions based on time.
When you cannot easily pass time as a parameter, a double for the clock can serve the same purpose. A test can replace the time-reading function with a stub that returns a fixed time, run the code under test, and then restore the original. This approach requires care, because the replacement must be restored even if the test fails, or later tests will see the fake time. Test tools often provide mechanisms for such temporary replacements, which handle restoration automatically. Learners should prefer passing time as a parameter when possible, and use clock doubles only when the code structure makes that difficult, because explicit parameters are clearer than hidden replacements.
Time tests should cover boundaries, just like other tests. Test the exact moment a greeting changes from morning to afternoon, the day before and after a birthday for an age calculation, and the transition across midnight, months, and years. These boundaries are where time logic most often goes wrong, through off-by-one errors or confusion about whether a boundary is inclusive. A set of tests with fixed times at and around each boundary documents the rules precisely, and because the times are fixed, the tests will pass or fail identically no matter when they are run. That stability is the goal: a test suite whose results depend on the code, not on the clock on the wall.
Check: Why does a test that depends on the real current time become flaky, and what is the simplest fix?
Section 25: Testing Random Behaviour
Randomness poses a similar challenge to time: a function that uses random numbers will produce different results on different runs, so a test that asserts a specific result will sometimes fail even when the code is correct. The solution is not to avoid testing random code, but to test the properties that should hold regardless of the random choices, and to control randomness where a specific result is needed. For example, a function that shuffles a list should be tested by checking that the result contains the same elements as the original, in some order, rather than by asserting a particular shuffled order. A function that picks a random item should be tested by checking that the result is one of the allowed items, and by running it several times to see that different items can appear, rather than by asserting a single expected item.
Controlling randomness is possible when a random generator accepts a seed, which is a starting value that determines the sequence of random numbers that follow. With the same seed, the same sequence is produced every time, which makes a test repeatable. If your function accepts a seed, or accepts a random generator as a parameter, a test can pass a fixed seed and assert the exact result that seed produces. This is useful for testing the mechanics of a function that uses randomness, such as a dice-rolling simulation or a random password generator with a known seed. In normal use, the seed is not fixed, so the program behaves randomly, but in tests, the fixed seed makes behaviour deterministic and checkable. Designing functions to accept an optional seed or generator is therefore a testability habit worth adopting.
Statistical properties require a different approach. A function that should produce roughly equal numbers of each outcome over many runs cannot be tested with a single run, because a single run may be unrepresentative by chance. Instead, run the function many times, count the outcomes, and check that the counts fall within a reasonable range, rather than demanding exact equality. For example, a coin-flip simulation over a thousand flips should produce roughly half heads and half tails, but not exactly half, and a test that demands exactly half would fail most of the time even for a correct implementation. Choose a tolerance that is wide enough to allow natural variation, but narrow enough to catch a genuinely broken generator, such as one that always returns the same value. Statistical tests are probabilistic, so they should be used sparingly, and their tolerance should be chosen thoughtfully.
Finally, test the edges of random functions: an empty list to choose from, a list with one item, a range with a single possible value, and a request for more items than are available. These cases often involve errors or special handling, and they should be tested with the same care as other edge cases. Random code is not exempt from testing; it simply requires tests that check properties and distributions rather than single exact outcomes, plus controlled-seed tests for the deterministic parts. With those two kinds of tests, you can have confidence in random behaviour without demanding the impossible, which is a specific random result on demand.
Check: How can you test a shuffling function without asserting one particular shuffled order?
Section 26: Testing Classes and Objects
Testing classes means testing both the individual methods and the way an object changes over time. A class often holds state, such as a balance, a list of items, or a count, and its methods change that state in ways that later methods depend on. A good test therefore follows a small story: create an object in a known starting state, call one or more methods, and assert both the returned values and the object's new state. For example, testing a shopping basket might involve creating an empty basket, adding two items, asserting that the item count is two and the total is correct, removing one item, and asserting the updated count and total. Each step checks that the object's state evolves as expected, not just that a single method returns a value.
Starting state matters. Many class bugs arise because a method assumes the object is in a state that it is not, such as assuming a list is non-empty, a connection is open, or a value has been set. Tests should cover objects in their initial state, in a typical state, and in edge states, such as empty, full, or just after a reset. A test that calls a method on a freshly created object checks the initial state handling, while a test that calls methods in an unusual order checks whether the class guards against misuse or handles it gracefully. Decide what should happen for invalid sequences, such as withdrawing from an empty account or removing an item that was never added, and write tests for those decisions, because callers will eventually try them, whether by mistake or by design.
Method independence within a class is also worth checking. Ideally, each method should do its job regardless of which methods ran before it, except where the object's state is explicitly meant to carry over. A test that calls methods in different orders can reveal hidden dependencies, such as a method that only works if another method was called first to initialise something. Such dependencies make a class fragile and hard to use, because callers must remember a secret sequence. If a test reveals such a dependency, consider whether the class should initialise the needed state in its constructor, or whether the method should handle the uninitialised case itself. Tests that explore method order are therefore not just checks; they are design feedback.
Finally, test classes through their public behaviour, not their internal details. A test that asserts on a private attribute or an internal variable will break whenever the implementation changes, even if the behaviour stays the same, which discourages healthy refactoring. Instead, assert on returned values and on observable state, such as values returned by public methods or properties that callers can legitimately use. If a class has no public way to observe some state, ask whether that state needs to be observable, or whether the test is checking something that callers cannot see and therefore cannot depend on. Testing through the public interface keeps tests robust across implementation changes, and it encourages class designs where behaviour is visible and well-defined.
Check: When testing a class, why should you assert on returned values and observable state rather than private internal attributes?
Section 27: Testing Modules and Larger Units
As programs grow beyond single functions, tests need to grow from checking individual functions to checking how functions and modules work together. A module test, sometimes called an integration test at a small scale, checks a small group of related functions as a unit, following a realistic workflow through them. For example, a module that manages tasks might have functions to add a task, list tasks, and mark a task complete. Testing each function alone is valuable, but testing the workflow, adding a task, listing it, marking it complete, and listing again to confirm it shows as done, checks that the functions cooperate correctly, sharing data in the way they are supposed to. Workflow tests catch integration bugs that unit tests miss, such as functions that disagree about a data format.
Module tests are slower and broader than unit tests, so they should be used selectively. A good suite has many fast unit tests for individual functions and a smaller number of module tests for important workflows. The unit tests give precise failure information, pointing at the exact function that broke, while the module tests give confidence that the pieces fit together. When a module test fails but all unit tests pass, the problem is likely in the interaction between functions, such as a mismatch in how data is passed or stored, and the investigation should focus on the boundaries between functions. When a unit test fails, the problem is likely inside that function, and the investigation is narrower. Both kinds of information are valuable, and a suite with only one kind is incomplete.
Test organisation becomes important at this scale. Group tests by the module or feature they cover, using files and folders that mirror the program's structure, so that a developer can find the tests for a feature quickly. Name test files clearly, such as test_tasks.py for a tasks module, and keep related tests together. This organisation also makes it easy to run a subset of tests, such as only the tests for the module you are changing, which speeds up development. Running the whole suite remains important before committing or sharing code, but during development, running a focused subset gives faster feedback, and a well-organised suite makes that easy.
Larger units also raise the question of shared setup. A module test may need a small data set, a temporary file, or a set of objects in a known state, and fixtures become more valuable here, because several workflow tests may share the same starting point. Use fixtures to provide that starting point fresh for each test, as before, so that workflow tests remain independent. Resist the temptation to chain workflow tests, where one test depends on another having created data, because that reintroduces ordering dependence at a larger scale. Each module test should set up its own world, run its workflow, and assert its outcome, leaving the world clean for the next test. That discipline keeps even larger test suites reliable and debuggable.
Check: What does a module or workflow test catch that individual unit tests might miss?
Section 28: Regression Tests
A regression test is a test written because a bug was found, to make sure that bug never returns. The name comes from the idea of regressing, or going backwards, to a state where the bug existed. When a user reports a problem, or when you discover one yourself, the disciplined response is to write a test that reproduces the bug, watch it fail, fix the code, and watch the test pass. That test then stays in the suite permanently, guarding against a future change that reintroduces the same bug. Without such a test, the same bug can be fixed, forgotten, and reintroduced months later, because nobody remembers the exact case that exposed it. A regression test is institutional memory in code form.
Writing a regression test starts with reproducing the bug in the smallest possible form. Take the failing example, strip away everything irrelevant, and reduce it to a single test with a clear input and expected output. This reduction often clarifies the bug itself, because it forces you to identify exactly which input triggers the problem and what the correct output should be. Sometimes the reduction reveals that the bug is not where you thought it was, or that it affects a broader range of inputs than the reported case. A minimal reproduction is valuable for fixing, because it lets you test a fix quickly, and it is valuable as a permanent test, because it is easy to understand and quick to run. Name the test in a way that hints at the bug, without including sensitive details about users or reports, so that future readers understand why it exists.
Regression tests also teach humility about fixes. A fix that makes the reported case pass but breaks another case is not a good fix, and the full test suite is the check. Run all tests after a fix, not just the new regression test, because a fix can have side effects. A common pattern is a fix that handles the reported input by adding a special case, which then breaks a different input that previously worked. The suite catches that trade-off, and the developer can then find a more general fix. Regression testing is therefore not just about the new test; it is about the interaction between the new test and the existing suite, which together define the behaviour that must be preserved.
Over time, a suite accumulates many regression tests, each guarding a bug that once cost someone time or trust. That accumulation is a strength, not clutter, provided the tests remain clear and fast. Periodically review regression tests to ensure they still make sense, and keep their names descriptive, so that a newcomer can understand what past problems they guard against. A suite rich in regression tests tells a story about a program's history: the mistakes it once made and the lessons it learned. That story is worth preserving, because it prevents the same mistakes from being repeated, and it gives users confidence that reported problems are taken seriously and fixed permanently.
Check: What are the steps for turning a reported bug into a regression test, and why should the test stay in the suite?
Section 29: Test Coverage
Test coverage is a measure of how much of your code is executed when the tests run. A coverage tool tracks which lines, branches, or functions were run during testing, and reports the proportion that were covered, often as a percentage, along with a list of lines that were never executed. Coverage is a useful guide, because it highlights code that no test touches, which is code whose behaviour is unverified. If a function, a branch, or an error handler never runs during testing, then a bug in that code could survive undetected, and coverage points you towards the tests you should write next. It turns the vague feeling that testing is incomplete into a concrete list of uncovered areas to investigate.
Coverage is a guide, not a goal, and treating a percentage as a target can mislead. A suite can achieve high coverage while still testing poorly, if its tests execute code without asserting meaningful outcomes. For example, a test that calls a function but never checks its return value will cover the function's lines without verifying its behaviour, and coverage will look good while the test is nearly useless. Conversely, a suite with lower coverage may test its covered areas thoroughly, with strong assertions and thoughtful edge cases. The number alone does not tell you which situation you are in. Use coverage to find untested code, then write tests that assert behaviour for that code, rather than tests that merely execute it to raise the number.
Branch coverage adds a useful refinement to line coverage. Line coverage asks whether each line ran; branch coverage asks whether each decision took each possible direction. A condition that is only ever true during testing has its false branch uncovered, even though every line ran, because the line for the false case may be shared or may not exist as a separate line. Branch coverage reveals such gaps, prompting tests for the missing direction. For decision-heavy code, branch coverage is therefore more informative than line coverage alone. Learners should understand both measures, but should not obsess over either. The goal is confidence in important behaviour, not a perfect percentage, and some code, such as defensive checks for conditions that are hard to trigger, may legitimately remain uncovered, provided that decision is conscious.
Use coverage as part of a review routine, not as a gate that blocks all work until it reaches an arbitrary number. Run coverage occasionally, look at the uncovered lines, and ask whether each one represents important behaviour that should be tested, or code that is trivial, defensive, or obsolete. Write tests for the important gaps, consider removing obsolete code, and accept that some defensive code may stay uncovered. That judgement, rather than the percentage itself, is the skill coverage tools are meant to support. A developer who uses coverage to ask good questions will write better tests than a developer who chases a number, because the questions lead to tests that check behaviour users care about.
Check: Why can a test suite have high coverage yet still test poorly?
Section 30: What Not to Test
Knowing what not to test is as important as knowing what to test, because a suite cluttered with low-value tests is slow, brittle, and hard to maintain, which discourages people from running it. As a rule, do not test code you did not write and do not control, such as the standard library or a well-established external library, unless your use of it involves a specific behaviour you depend on. Testing that a standard sorting function sorts correctly adds no value, because that function is already thoroughly tested by its maintainers, and your test will only fail if the library changes in a way that is already well documented. Instead, test how your code uses the library: that you pass the right inputs, handle its outputs correctly, and deal with its errors appropriately. That is where your bugs will be, not inside the library itself.
Do not test trivial code that has no behaviour worth verifying, such as a function that only returns a constant, or a simple property that returns a stored value, unless that value is part of a contract that callers depend on. Such tests add maintenance cost without adding confidence, because the code is so simple that reading it is sufficient verification, and a test for it will only fail if someone changes the constant, which is a deliberate act that a test cannot usefully guard against. Similarly, do not test implementation details that callers cannot observe, such as the internal order of operations or the exact type of an intermediate value, because those details may change during refactoring without affecting behaviour, and tests tied to them will break unnecessarily, discouraging refactoring.
Do not test the same behaviour many times in slightly different forms, unless each form adds a distinct case, such as a different boundary or a different error. Duplicated tests waste run time and maintenance effort, and they create confusion when one copy is updated and another is forgotten. Parameterisation, discussed earlier, is the remedy for near-duplicate tests: express the pattern once, with a table of distinct cases. Also avoid tests that are so broad they check everything at once, because their failures are vague, as discussed in the section on small tests. The sweet spot is a test that checks one meaningful behaviour, with an input that represents a distinct case, and an assertion strong enough to catch a real bug in that behaviour.
Finally, do not write tests that depend on the environment in ways you cannot control, such as the exact contents of a user's home folder, the current network status, or the precise timing of an operation, unless you have isolated those dependencies with doubles or temporary data. Environment-dependent tests are flaky, and flaky tests erode trust in the whole suite, because developers learn to ignore failures, assuming they are environmental rather than real. If a test cannot be made reliable, it is better to redesign the code for testability, or to move the test to a separate, slower suite that runs less often, than to keep a flaky test in the main suite. A trustworthy suite is one where a failure means something is wrong with the code, and keeping it trustworthy requires regularly removing or fixing tests that do not meet that standard.
Check: Why is testing a well-established library function itself usually a waste of effort, and what should you test instead?
Section 31: Organising Test Files
A well-organised test suite mirrors the program it tests, so that finding a test is as easy as finding the code it covers. The usual convention is to keep tests in a folder named tests, with test files named after the modules they cover, prefixed with test, such as test_tasks.py for a tasks module. Inside each file, tests are grouped by the function or class they cover, often with a naming pattern that makes the grouping obvious. This mirroring is not just tidiness; it is a practical aid during development and debugging. When a function changes, you know exactly which test file to open. When a test fails, you know which part of the program it relates to. When a new developer joins, they can navigate the suite by following the program's structure, rather than learning a separate organisation.
Within a test file, order tests in a way that tells a story about the feature, starting with the simplest behaviour and progressing to edge cases, errors, and complex workflows. A reader who opens the file should be able to follow the feature's behaviour from top to bottom, as if reading a specification. Group related tests together, and separate groups with blank lines or comments that name the behaviour being tested, so that the file scans easily. Avoid scattering tests for one function across several files, unless the function is used in very different contexts that genuinely belong in different suites, because scattering makes it hard to see the full picture of what is tested and what is missing. Cohesion within a test file mirrors cohesion within the code.
Shared test code also needs a home. Fixtures and helpers that are used by many test files should live in a shared location that the test tool loads automatically, or in a clearly named helpers module that test files import. Helpers used by only one file should live in that file, close to the tests that use them, so that a reader does not need to hunt elsewhere. This placement rule, local helpers locally, shared helpers centrally, keeps indirection to a minimum. A test file that imports helpers from five different places is hard to follow, while a test file that defines its own small helpers at the top is self-contained and readable. As with production code, the goal is locality: keep related things close together, and keep shared things in one obvious place.
Finally, keep test files free of production logic. Tests should not contain the algorithms they are testing, reimplemented in a slightly different form, because then a bug in the algorithm may be duplicated in the test, and both will agree while both are wrong. Tests should contain examples, expectations, and assertions, not alternative implementations. If a test needs to compute an expected value, compute it by hand or from a simple, independent source, not by running the same logic the program uses. A test file that reads like a list of examples is a good test file. A test file that reads like a second implementation is a warning sign, and it should be simplified until the examples stand out clearly.
Check: How should test files and folders mirror the program they test, and why does that help?
Section 32: Running Part of a Suite
Running the whole test suite is the gold standard before sharing code, but during development, running only the relevant part of the suite gives faster feedback and keeps you focused. Test tools let you select tests by file, by name, or by keyword, so you can run just the tests for the module you are changing, or just the single test that is failing, without waiting for hundreds of other tests. This selective running is especially useful when debugging, because you can iterate quickly: change the code, run the one test, read the result, and repeat, without the noise of unrelated tests. It is also useful when a suite is large, because a full run may take minutes, while a focused run takes seconds, and seconds encourage frequent running, while minutes discourage it.
Selective running has a risk: a change that breaks an unrelated test will not be caught by a focused run, because the unrelated test was not run. The remedy is to run the full suite regularly, at natural checkpoints, such as before committing code, before sharing it, and after finishing a feature. Think of focused runs as a development loop and full runs as a verification loop. The development loop gives speed, the verification loop gives safety, and you need both. A developer who only ever runs focused tests may be surprised by a full-suite failure late in the process, when the cause is harder to trace, while a developer who only ever runs the full suite may run tests too rarely, because each run feels expensive. Balance the two, and make full runs a habit at checkpoints, not an afterthought.
Test selection also helps with triage when many tests fail. If a change breaks ten tests, running them one at a time, or by group, can reveal whether they share a common cause or represent separate problems. Often, several failures trace back to a single change, such as a renamed function or a changed data format, and fixing that one change resolves all of them. Running tests by group, such as all tests for one module, helps you see the pattern, because the failures cluster around the affected area. Running tests individually helps you confirm a fix, because you can watch each failing test turn green in turn. Both views are useful, and a good test tool makes switching between them easy.
Finally, learn how your test tool reports results for a partial run, so that you do not mistake a partial pass for a full pass. A partial run that passes means only that the selected tests passed, not that the program is correct. Make it a habit to note, mentally or in a checklist, whether the last run was partial or full, and run the full suite before considering work complete. Some teams automate full runs, so that every change triggers a complete suite run without human effort, which removes the risk of forgetting. For a learner, the manual habit is sufficient, provided it is consistent: focused runs while working, full runs at checkpoints, and a clear understanding of what each run did and did not check.
Check: Why should focused test runs during development always be followed by a full suite run at checkpoints?
Section 33: Reading Failure Messages
A failure message is a report, and learning to read it well is one of the fastest ways to improve as a debugger. A typical failure message includes the name of the failing test, the file and line where the failure occurred, the type of failure, and a comparison of expected and actual values, or a traceback showing the chain of calls that led to an error. Start by reading the test name, because it tells you which behaviour broke. Then read the assertion or error line, because it tells you what was expected and what actually happened. Then follow the traceback, if there is one, from the test downwards, to find where the program's code contributed to the failure. Reading in this order, from behaviour to detail, prevents you from getting lost in a long traceback before understanding what the test was checking.
Tracebacks deserve special attention, because beginners often find them intimidating. A traceback is a list of function calls, most recent last, showing how the program reached the point of failure. The most useful lines are usually the last few, which show the code that actually failed, and the test's own line, which shows what the test was doing. Lines in the middle, showing library or framework internals, are often less relevant, unless the failure is in how your code uses a library. Learn to skim a traceback for your own files first, and to ignore library frames unless they point at a misuse. With practice, a traceback becomes a map, not a wall of text, and you will navigate it quickly to the relevant code.
Assertion failures and errors are different kinds of failure, and they call for different responses. An assertion failure means the program ran to completion but produced the wrong result, so the investigation focuses on logic: why did the calculation, the filtering, or the formatting go wrong? An error, such as an exception raised during the test, means the program did not complete normally, so the investigation focuses on what went wrong during execution: a missing key, a wrong type, a file that did not exist, or a division by zero. The traceback for an error shows where the exception was raised, which is often the key clue. Distinguishing these two kinds of failure helps you ask the right first question, rather than treating every failure as the same kind of problem.
Finally, reproduce a failure in isolation before fixing it. Run the single failing test on its own, and confirm that it still fails, which rules out test dependence as the cause. Then add a small amount of investigation, such as printing the actual value, or checking the input data, but do not change the code blindly in the hope that the failure will disappear. A fix made without understanding the cause may hide the bug rather than solving it, or may break another test. Read the message, understand the mismatch, form a hypothesis about the cause, and then change the code to address that cause. When the test passes after your change, you will know why, and that knowledge is what prevents the same bug from returning in a different form.
Check: What should you read first in a failure message, and how does that order help you debug?
Section 34: Debugging a Failing Test
Debugging a failing test begins with a question that is easy to forget under pressure: is the test right, or is the code right? A failing test means the test's expectation and the code's behaviour disagree, but it does not tell you which side is wrong. Sometimes the code has a bug, and the test correctly caught it. Sometimes the test has a bug, such as a wrong hand calculation, a mistaken input, or an expectation based on a misunderstood requirement, and the code is actually correct. Sometimes both are wrong in different ways. The first step in debugging is therefore to check the test itself, by re-reading the requirement, reworking the example by hand, and confirming that the test's arrangement matches the scenario it claims to test. Fixing code to satisfy a wrong test makes the program worse, not better.
If the test is right, the next step is to narrow the problem. Run the failing function directly with the test's input, outside the test, and observe the actual output. Compare it with the expected output, and look for a pattern: is the result off by one, reversed, missing an item, or of the wrong type? Patterns point at causes. An off-by-one result suggests a loop boundary or a threshold error. A missing item suggests a filtering condition that is too strict. A reversed result suggests an ordering mistake. A wrong type suggests a conversion that was missed or applied incorrectly. This kind of pattern reading turns a vague failure into a specific hypothesis, which you can then check by reading the relevant code, rather than reading the whole program in the hope that the bug will stand out.
Isolating the problem further often involves simplifying the input. If a test fails with a complex input, try a simpler input that still fails, and keep simplifying until you have the smallest input that reproduces the failure. This minimal case is easier to reason about, easier to discuss, and often reveals the bug directly, because the irrelevant details are stripped away. It also makes a good regression test, as discussed earlier, because it captures the bug in its simplest form. If simplifying the input makes the failure disappear, the boundary between failing and passing inputs is itself informative, because it tells you which feature of the input triggers the bug, and you can focus your investigation on code that handles that feature.
Finally, when you think you have found the cause, fix it in the smallest way that addresses the cause, not the symptom, and run the failing test again, plus the full suite, to confirm the fix and check for side effects. Resist the urge to rewrite the function while fixing a bug, because a rewrite introduces new risks and makes it hard to tell whether the fix worked or the rewrite changed the behaviour in another way. Fix first, verify, and refactor separately, under the protection of passing tests. Debugging a failing test is therefore a disciplined loop: verify the test, narrow the failure, find the cause, fix minimally, and verify again. That loop, practised on small failures, scales to the largest debugging challenges you will face.
Check: Why is the first question when a test fails whether the test or the code is wrong?
Section 35: Tests as Documentation
Tests document behaviour in a way that prose documentation cannot, because tests are checked every time they run. A paragraph that says a function handles empty lists may become outdated if the function changes, without anyone noticing, but a test that asserts empty-list behaviour will fail if the function stops handling empty lists, alerting everyone immediately. That self-checking property makes tests a living specification: a description of what the program does that cannot drift silently from reality. When a new developer joins a project, reading the tests gives them a reliable picture of intended behaviour, including edge cases and error handling, which prose documentation often omits or describes vaguely. Tests are therefore not just a safety net; they are a primary source of truth about a program.
For tests to serve as documentation, they must be readable by humans, not just runnable by tools. Descriptive names, clear arrangements, and simple assertions all contribute to readability, as discussed in earlier sections. A test that reads like a small story, with a setup that is easy to grasp, an action that is obvious, and an assertion that states the expected outcome plainly, documents its behaviour without requiring the reader to decode clever code. A test that relies on obscure helpers, complex setup, or cryptic data does not document well, even if it passes, because a human reader cannot easily extract the behaviour it checks. Write tests for two audiences: the tool that runs them, and the human who reads them later, and prioritise the human when the two conflict, because the tool will run almost any test, while the human must understand it to maintain it.
Tests also document decisions and trade-offs that prose might gloss over. A test for a boundary case documents a decision about where a threshold lies. A test for an error case documents a decision about which inputs are invalid. A test for a particular ordering documents a decision about what order callers can rely on. Each of these decisions, recorded as a test, answers a question that a future maintainer will ask, without requiring them to track down the original author. When a decision changes, updating the test and its name records the new decision, preserving the history of the program's contract in a form that is always current. That is why test names should state the behaviour and the condition, not just the function being tested: the name is the heading of the documentation entry.
Finally, treat test documentation as a complement to, not a replacement for, other documentation. Tests show examples and edge cases, but they do not explain why a feature exists, how it fits into a larger system, or how a user should think about it. Those explanations belong in guides, comments, and docstrings, which should reference the tests where relevant, rather than duplicating their examples. Together, prose documentation and tests give a complete picture: prose explains the why and the overall shape, tests pin down the exact behaviour at every important point. A project with both is far easier to understand, maintain, and extend than a project with only one.
Check: Why is a test a more reliable form of documentation than a prose paragraph describing the same behaviour?
Section 36: Unit Versus Integration Tests
Unit tests and integration tests differ in scope, speed, and purpose, and understanding the difference helps you choose the right kind for each situation. A unit test checks a small piece of code in isolation, usually a single function or method, with its dependencies replaced by doubles or controlled inputs. It is fast, precise, and easy to debug, because a failure points at a small area. An integration test checks several pieces working together, such as a function that reads a file, processes its contents, and writes a result, or a module that coordinates several functions to complete a workflow. It is slower and broader, and a failure may require more investigation, but it checks something unit tests cannot: that the pieces fit together correctly, agreeing on data formats, shared state, and sequencing.
The two kinds of tests complement each other, and a healthy suite contains both, in proportions that reflect their strengths. Many unit tests give precise, fast feedback during development, catching logic errors early and pinpointing their location. Fewer integration tests give confidence that the system works as a whole, catching interface mismatches, configuration errors, and assumptions that hold in isolation but fail in combination. A suite with only unit tests may pass while the system fails, because the pieces were tested separately but never together. A suite with only integration tests may catch failures but give vague information about their cause, because a failure could be in any of the pieces involved. The combination, often described as a pyramid with many unit tests at the base and fewer integration tests above, balances precision and realism.
Speed is a practical factor in this balance. Unit tests, being isolated and fast, can run in seconds, encouraging frequent runs during development. Integration tests, involving files, databases, or services, may take much longer, and running them too often can slow development. A common practice is to run unit tests constantly, integration tests at checkpoints, and a full suite, including slower tests, before sharing code. This tiered approach keeps feedback fast where it matters most, while still verifying the whole system regularly. Learners with small programs may not feel this difference yet, but the habit of distinguishing fast, focused tests from slower, broader ones will serve them well as their programs grow.
Finally, the distinction is not always sharp, and that is fine. A test that checks two functions together is a small integration test, even if it is not labelled as one, and a test that checks a function with a real file is partly an integration test, because it involves the file system. Do not worry about labelling every test perfectly. Worry about whether your suite, taken together, checks both individual logic and realistic workflows, quickly and reliably. If you notice that all your tests are narrow and isolated, add a few workflow tests. If all your tests are broad and slow, add unit tests for the tricky logic. The goal is a suite that is both precise and realistic, and the unit-integration distinction is a tool for achieving that balance, not a rigid classification to enforce.
Check: How do unit tests and integration tests complement each other in a healthy suite?
Section 37: Testing Command-Line Behaviour
Command-line programs have behaviour that goes beyond the functions inside them: they parse arguments, print output, read input, and exit with a status that tells the caller whether they succeeded. Testing that behaviour requires a different approach from testing a pure function, because the program's interface is the terminal, not a function call. The most testable design separates the logic from the command-line handling: core functions do the work and return values, while a thin command-line layer parses arguments, calls those functions, and prints results. That separation lets you test the core logic with ordinary unit tests, and test the command-line layer separately, by checking how it handles arguments, output, and errors, without needing to run the whole program as a separate process for every test.
When you do test command-line handling, focus on the observable behaviour: given certain arguments, what output is produced, and what exit status results? A test can invoke the argument-parsing function with a list of arguments, rather than with real command-line input, and assert on the parsed values or on the output produced. For output, a test can capture what the program prints, by redirecting output to a buffer that the test can inspect, rather than letting it go to the terminal. This capture technique lets you assert that a success message, a help message, or an error message appears as expected, without manual inspection. For errors, such as missing required arguments or invalid values, assert that the program reports the problem clearly and exits with a failure status, rather than crashing with a confusing traceback or silently doing nothing.
Input handling also needs tests. A program that reads from standard input, or that prompts the user for values, should be tested with controlled input, supplied as a string or a stream that the test provides, rather than by typing at a terminal during the test. This makes the test repeatable and automatable. Test the program with typical input, with empty input, and with malformed input, and check that it handles each gracefully, either by processing it, by reporting an error, or by asking again, depending on the design. Interactive programs are harder to test than batch programs, which is another reason to keep the core logic separate from the interaction layer: the logic can be tested thoroughly, while the interaction layer needs only a few tests for its handling of input and output.
Finally, remember that command-line behaviour includes help and usage messages, which users rely on when they forget how to use a program. Test that requesting help produces a message that lists the available commands and options, and that an invalid command produces a helpful error rather than a silent failure. These messages are part of the program's interface, and they deserve tests just as much as the core logic does. A program that works correctly but cannot explain itself is hard to use, and a test that checks its messages ensures that explanations stay accurate as the program evolves. Command-line testing, like other testing, is about observable behaviour, and the terminal is simply another interface through which behaviour can be observed and checked.
Check: Why is separating core logic from command-line handling helpful for testing a command-line program?
Section 38: Testing Validation Logic
Validation logic checks whether input meets the rules a program requires, such as whether a name is non-empty, an age is within a range, an email address has a plausible shape, or a quantity is positive. Testing validation thoroughly is important, because validation is the program's first line of defence against bad data, and a validation bug can allow invalid data to cause problems far downstream, where the cause is harder to trace. A good validation test suite includes tests for valid inputs, for each kind of invalid input, and for boundary values where valid becomes invalid. Each rule should have its own tests, so that a failure points at the specific rule that broke, rather than at validation in general.
Valid inputs deserve tests too, not just invalid ones, because a validation rule that is too strict will reject legitimate data, frustrating users and blocking real work. Test with realistic valid examples, including examples at the edges of validity, such as the shortest allowed name, the longest allowed name, the minimum allowed age, and the maximum allowed age. These tests ensure that the validation accepts what it should, as well as rejecting what it should not. A validation suite that only tests rejection may pass while the program wrongly rejects valid users, which is a failure that users will notice quickly and complain about loudly. Balance is essential: test acceptance and rejection with equal care.
Invalid inputs should be tested one rule at a time. If a form has three fields, each with its own rules, a test that submits three invalid fields at once may fail for any of the three reasons, and it will not tell you which rule broke if it starts passing or failing unexpectedly. Instead, write tests where one field is invalid and the others are valid, so that each test isolates one rule. This isolation also helps when requirements change, because updating one rule affects only its own tests. For each invalid case, assert not only that validation fails, but that it fails in the expected way, such as returning a clear error message for the right field, or raising the expected error type. The manner of failure matters, because callers and users depend on it to correct their input.
Validation often involves combinations, such as a rule that depends on two fields together, for example a start date that must be before an end date, or a password that must match its confirmation. Test these combinations with cases where each field is individually valid but the combination is invalid, as well as cases where the combination is valid. These combination tests catch logic that single-field tests miss, because the individual fields pass their own rules while the pair fails a joint rule. Document each validation rule in a test, with a name that states the rule and the case, so that the suite as a whole becomes a precise specification of what the program accepts and rejects. That specification is invaluable when requirements change or when a new developer needs to understand the program's input rules.
Check: Why should validation tests isolate one invalid field at a time, rather than submitting many invalid fields together?
Section 39: Testing Sorting and Searching
Sorting and searching are fundamental operations, and their tests should reflect the properties that define correct behaviour, rather than just a few examples that happen to pass. For sorting, the defining properties are that the result contains the same elements as the input, that the elements are in the required order, and that the input is either unchanged or changed as specified. A test that checks only that the output is ordered may pass even if an element was lost or duplicated, because an ordered list can still be wrong. A test that checks only that the output contains the same elements may pass even if they are out of order. A thorough sorting test checks all three properties: same elements, correct order, and correct handling of the input. That combination is strong enough to catch most sorting bugs.
Sorting tests should include the usual edge cases: an empty list, a list with one element, a list that is already sorted, a list in reverse order, a list with duplicate elements, and a list with elements that are equal in the sorting key but different in other respects. Duplicates are especially informative, because they test whether the sort is stable, meaning whether equal elements keep their original relative order, if stability is promised, or whether duplicates are handled at all. Already-sorted and reverse-sorted inputs test whether the algorithm handles extremes efficiently and correctly, while a random-looking input tests the general case. Together, these cases give a realistic picture of sorting behaviour across the range of inputs a program will actually encounter.
Searching tests follow a similar pattern. A search function should be tested with an element that is present, an element that is absent, an empty collection, a collection with one element, and a collection with duplicates, where the expected result may be the first match, any match, or a count, depending on the specification. Test searching at the start, middle, and end of a collection, because off-by-one errors often show up at the boundaries. Also test searching with elements that are similar but not identical to the target, such as strings that differ by case or by a single character, to ensure the comparison is exact in the way the specification requires. A search that matches too loosely will return false positives, while a search that matches too strictly will miss valid results, and tests should distinguish those failures.
Finally, test sorting and searching with the data types the program actually uses, not just with simple numbers. Sorting names involves case, whitespace, and possibly characters from different languages, while sorting dates involves chronological order rather than alphabetical order. Searching in a list of dictionaries involves comparing a field within each dictionary, not the dictionary itself. Tests with realistic data types catch type-related bugs, such as comparing numbers as strings, which would order ten before two, or comparing dates as strings, which may work in one format and fail in another. Realistic data, combined with property checks and edge cases, makes sorting and searching tests trustworthy, which matters because these operations underpin so much of what programs do.
Check: For a sorting test, why is it not enough to check only that the output is in order?
Section 40: Property-Based Thinking
Property-based thinking is a way of testing that focuses on general properties a function should always satisfy, rather than on specific examples alone. An example-based test says that doubling three gives six. A property-based test says that doubling any number gives twice that number, or that sorting any list gives a list with the same elements in order. Properties are more general than examples, and they can catch bugs that examples miss, because they apply to a whole range of inputs, not just the ones you thought to list. You do not need a special tool to practise property-based thinking; you can write ordinary tests that check a property for several chosen inputs, including edge cases, and that is already a step beyond single examples.
Useful properties often come from the definition of the operation. Reversing a list twice should give the original list. Adding an item to a collection and then removing it should leave the collection unchanged. Encoding and then decoding a value should return the original value. Sorting a list and then sorting it again should give the same result, because sorting should be idempotent. These round-trip and idempotence properties are powerful, because they do not require you to know the exact intermediate result, only that the overall behaviour is consistent. They are also easy to explain, which makes them good documentation. When you discover a property for a function, write a test for it, with inputs that include typical, edge, and random-looking values, and you will have a test that is both simple and broad.
Property-based testing with a tool goes further, by generating many random inputs automatically and checking the property for each one. The tool may generate hundreds of cases, including inputs you would never have thought of, and if a case fails, it tries to shrink the failing input to the smallest example that still fails, making the bug easier to understand. This automation is valuable, but it is not magic: you still need to state the property correctly, and a wrong property will produce misleading failures. Learners should start by thinking in properties and writing example-based property tests by hand, before adopting a tool, so that they understand what the tool is checking and why. The thinking is the skill; the tool is an amplifier for it.
Properties also help you notice when a function is underspecified. If you cannot state a property that a function should satisfy, beyond giving examples, you may not yet understand what the function is supposed to do in general. That confusion is useful feedback, prompting you to clarify the specification before writing more code. Conversely, if you can state several properties, you have a strong basis for tests, because each property becomes a test, and together they constrain the function's behaviour tightly. Property-based thinking therefore improves both testing and design, by forcing you to articulate general rules rather than relying on a handful of examples that may not generalise.
Check: What is the difference between an example-based test and a property-based test, and why are properties more general?
Section 41: Testing in Teams
Testing in a team is a shared agreement, not just a personal habit. When several people work on the same program, tests are how they protect each other's work. A developer who changes a shared function can run the tests written by colleagues and discover immediately whether the change breaks something someone else depends on, without needing to ask each person individually. That protection only works if the team agrees on conventions: where tests live, how they are named, and what must pass before code is shared. A team that agrees on these basics, even informally, avoids the confusion of tests scattered in different places, named inconsistently, or run only sometimes. Conventions are not bureaucracy; they are the infrastructure that lets tests do their job across people.
Code review is where team testing becomes visible. When a developer shares a change, reviewers should look at the tests as carefully as they look at the code, asking whether the tests cover the new behaviour, whether they include edge cases and error cases, and whether they are readable enough for others to maintain. A change without tests, or with tests that only cover the happy path, should prompt a conversation, not automatic acceptance. Reviewers should also check that existing tests were not weakened to make a change pass, such as by loosening an assertion or deleting a test that was inconvenient. Weakening a test to pass a change is a serious matter, because it removes protection that others rely on, and it should only happen when the behaviour genuinely changed and the team agrees on the new behaviour. Review is therefore a guard for the test suite itself, not just for the code.
Shared ownership of tests also means shared responsibility for failures. When a test fails in a shared suite, the team should treat it as a signal to investigate, not as someone else's problem, and not as noise to ignore. A culture where failing tests are routinely ignored, or where developers work around them by skipping tests, quickly loses the benefits of testing, because the suite's failures no longer mean anything. The healthy response to a failing test is to investigate promptly, determine whether the test or the code is wrong, fix the right side, and share what was learned. That response takes time, but it is far cheaper than the time lost to bugs that a neglected suite would have caught. Teams that protect their suite's trustworthiness, by keeping it fast, reliable, and green, get the full value of testing.
Finally, teams benefit from agreeing on what to test and what not to test, as discussed earlier, so that effort is spent where it matters. A team that tests everything exhaustively, including trivial code and library internals, will have a slow, brittle suite that discourages running. A team that tests only the happy path will have a fast suite that catches little. The balance, many unit tests for important logic, workflow tests for key features, regression tests for past bugs, and a willingness to remove low-value tests, is a team decision that should be revisited as the program grows. Document the team's testing approach briefly, in a place new members can find, so that everyone starts from the same understanding and the suite grows coherently rather than chaotically.
Check: Why should code reviewers examine tests as carefully as the code in a shared change?
Section 42: Keeping Tests Fast
Test speed matters because it determines how often tests are run. A suite that runs in a few seconds will be run after every small change, giving immediate feedback and catching mistakes early. A suite that takes many minutes will be run rarely, perhaps only before sharing code, which means mistakes are discovered late, when they are harder to trace. Speed is therefore not a luxury; it is a factor that shapes developer behaviour. Keeping tests fast is mostly about avoiding unnecessary work in tests: slow operations, such as network calls, large file operations, or long waits, should be replaced with doubles, temporary data, or smaller inputs wherever possible, so that the test checks the logic without paying the cost of the real operation.
One common source of slowness is testing with unnecessarily large data. A function that processes a list does not need a list of ten thousand items to prove it works; a list of a few well-chosen items, including edge cases, usually proves the same logic much faster. Large data is occasionally needed, such as when testing performance or handling at scale, but those tests belong in a separate, slower suite, not in the main suite that runs constantly. Similarly, tests that sleep or wait for a fixed time, such as waiting for a timeout, are slow by design and should be replaced with controlled doubles where possible, or moved to a slower suite. Every second saved in the main suite is a second that encourages another run, and those runs compound into earlier bug detection.
Another source of slowness is repeated setup. If many tests each build the same large data set from scratch, the total setup time can dominate the suite. Fixtures and helpers can reduce this cost, but only if they are efficient. A fixture that builds a large object for every test, even for tests that only use a small part of it, wastes time. Consider whether a fixture can be simplified, or whether tests can share a read-only setup that is built once and never modified, which is safe if no test mutates it. Be careful with shared setup, because mutation breaks independence, as discussed earlier, but read-only sharing is a legitimate optimisation when it is genuinely safe. Measure before optimising, however, because guessing about slowness often leads to optimising the wrong thing. Run the suite with timing information, find the slowest tests, and address those first.
Finally, keep the suite's speed visible. If the suite starts to slow down, notice it early and address it, rather than letting slowness creep up until running tests feels painful. A team can set an informal target, such as the main suite running in under a minute, and investigate when it exceeds that target. Removing slow, low-value tests, replacing real services with doubles, and splitting slower tests into a separate suite are all legitimate responses. Speed and thoroughness must be balanced: a fast suite that tests little is not useful, but a thorough suite that nobody runs is equally useless. The goal is a suite that is thorough enough to trust and fast enough to run often, and maintaining that balance is an ongoing part of testing, not a one-time achievement.
Check: Why does test speed affect how often developers run tests, and why does that matter?
Section 43: Tests and Refactoring in Practice
Refactoring in practice is messier than in theory, because real code has dependencies, shared state, and behaviour that is not fully tested. The practical approach is to strengthen the safety net before you start: write or improve tests for the area you plan to refactor, run them to confirm they pass, and only then begin changing the structure. This preparation step is often skipped under time pressure, but it is the step that makes refactoring safe. Without tests for the area, you are refactoring blind, relying on manual checking that is easy to get wrong. With tests, you have a concrete check for each behaviour you care about, and you can refactor with confidence, knowing that a mistake will be reported rather than silently shipped.
During refactoring, keep changes small and run tests frequently, as discussed earlier, but also be prepared for tests to reveal that your understanding of the code was incomplete. A test that fails after a supposedly safe change may indicate that the change was not as safe as you thought, or that the code had a hidden dependency you did not know about. Treat such a failure as information, not as an annoyance. Investigate it, understand the hidden behaviour, and decide whether the behaviour should be preserved, documented in a test, or changed deliberately. Refactoring often teaches you things about code you thought you understood, and those lessons are valuable, even when they slow you down. A refactoring that reveals a hidden behaviour and documents it in a test leaves the code better understood than before, which is a genuine improvement.
After refactoring, review the tests themselves. Refactoring code often makes some tests redundant, or reveals that tests were tied to the old structure rather than to behaviour. Update test names, remove tests that no longer check meaningful behaviour, and add tests for behaviour that the refactoring exposed or clarified. This test maintenance is part of the refactoring, not an optional extra, because a suite that still reflects the old structure will be confusing to future readers, even if it passes. A clean refactoring leaves both code and tests cleaner, with names and structure that match the new design. If you find yourself leaving tests in a messy state after refactoring, the refactoring is not yet complete.
Finally, know when not to refactor. Code that is working, well-tested, and rarely changed may not benefit from refactoring, and the risk of introducing a bug may outweigh the benefit of tidier structure. Code that is about to be replaced, or that is outside the scope of your current task, is usually best left alone. Refactoring is a tool for improving code you need to work with, not a duty to apply everywhere. Use tests to guide the decision: if the area has good tests, refactoring is low-risk and often worthwhile. If it has poor tests, either improve the tests first or leave the code alone until you have time to do so properly. That judgement, balancing benefit against risk, is part of professional practice, and tests are what make the risk calculable.
Check: Why should you strengthen tests for an area before refactoring it, rather than refactoring first?
Section 44: Testing for Accessibility of Logic
Accessibility in testing usually brings to mind user interfaces, but the underlying idea, that programs should work for a wide range of users and inputs, applies to logic tests too. A function that handles names, dates, or text should be tested with inputs that reflect real diversity: names with apostrophes, hyphens, and characters from different writing systems, dates in different formats where relevant, and text in different languages. A program tested only on simple, familiar examples may fail for users whose names or data do not fit those examples, and such failures are not just bugs; they exclude people. Testing with diverse, realistic inputs is therefore both a quality practice and a fairness practice, ensuring that the program serves all its users, not just those whose data resembles the developer's own.
Diverse testing also catches assumptions that are easy to miss. A function that assumes a name has exactly two parts, a first name and a surname, will fail for names with one part, three parts, or a different structure. A function that assumes text is in English will fail for text with accents or characters outside the basic alphabet. A function that assumes a date is in one format will fail for dates written differently. Each of these assumptions, if untested, becomes a hidden requirement that users must satisfy, often without knowing it. Tests with diverse inputs expose those assumptions, forcing a decision: either the program should handle the diversity, or it should clearly state its limitations and handle unsupported inputs gracefully, rather than crashing or producing wrong results. Either outcome is better than silent failure.
When designing diverse tests, use realistic examples, not caricatures, and avoid relying on stereotypes. Choose names, places, and data that genuinely occur in the world, and treat them as ordinary cases, not as exotic exceptions. A test with a name containing an apostrophe should sit alongside tests with simpler names, as an equal member of the suite, not as a special case marked as unusual. That normalisation matters, because it signals that diversity is part of normal operation, not an edge case to be tolerated. Similarly, test with empty, long, and unusual inputs as part of the ordinary range, because real data includes all of these, and a program that handles only the middle of the range is incomplete.
Finally, remember that diverse testing is not a substitute for involving diverse users or for following accessibility guidance for interfaces, but it is a concrete step a developer can take alone, in the test suite, to reduce exclusion. Every function that processes user data is a place where diversity can be tested, and every test that includes a diverse example is a small guard against a program that works for some and fails for others. Make diversity a routine part of test data design, alongside edge cases and boundaries, and your programs will be more robust, more fair, and more ready for real users, whose data will always be more varied than any textbook example.
Check: How does testing with diverse, realistic inputs improve both quality and fairness?
Section 45: When Tests Fail in the Real World
In real projects, tests fail for reasons that have nothing to do with the code being tested, and learning to handle those failures calmly is part of professional practice. A test may fail because the environment changed: a dependency was updated, a file path changed, a service became unavailable, or the clock moved across a boundary. A test may fail because it was flaky, passing most of the time but failing occasionally due to timing, ordering, or randomness that was not controlled. A test may fail because another developer changed a shared fixture or helper, affecting many tests at once. Each of these causes calls for a different response, and the first step is always to investigate, not to assume the failure is unimportant or to rerun the test repeatedly until it passes by chance.
Flaky tests deserve special attention, because they are corrosive. A test that fails one time in twenty teaches developers to distrust failures, and once trust is lost, real failures are ignored alongside flaky ones, which defeats the purpose of the suite. When you encounter a flaky test, do not simply rerun it and move on. Investigate the cause: uncontrolled time, uncontrolled randomness, shared state, dependence on order, or dependence on an external service are the usual suspects. Fix the cause, by controlling the variable, isolating the state, or replacing the service with a double, and if the test cannot be made reliable, move it to a separate suite or quarantine it, with a clear note, rather than leaving it in the main suite to erode trust. A suite with no flaky tests is worth the effort, because its failures mean something.
Environmental failures also need a systematic response. If a test fails because a dependency changed, check whether the change was intentional and whether the code or the test needs updating to match the new behaviour. If a test fails because a service is unavailable, consider whether the test should use a double instead of the real service, so that it does not depend on availability. If a test fails because of a configuration difference between computers, consider whether the test should control its configuration through fixtures or temporary data, rather than relying on the machine's setup. Each environmental failure is an opportunity to make the test more self-contained and reliable, and a suite that improves in response to failures becomes more robust over time.
Finally, treat test failures as a team signal, not a personal blame event. A failure does not mean someone did a bad job; it means the suite found a mismatch, which is its job. Responding with curiosity, rather than defensiveness, leads to better fixes and a healthier culture. Share what you learned from a failure, especially if it reveals a pattern that could affect other tests, and update documentation or conventions if the failure suggests a gap. Real-world testing is not about achieving a permanently green suite; it is about maintaining a suite that reliably signals when something needs attention, and responding to those signals promptly and thoughtfully. That ongoing practice, more than any single test, is what makes testing valuable in real projects.
Check: Why are flaky tests corrosive to trust in a whole test suite, even if they fail only rarely?
Section 46: A Testing Workflow for Small Projects
A small project does not need a complex testing workflow, but it does need a consistent one, because consistency is what turns testing from an occasional effort into a habit. A simple workflow has four steps: write a test for the next behaviour, watch it fail, write the code to make it pass, and run the full suite to check for side effects. Repeat that cycle for each behaviour, in small steps, and keep the test files alongside the code, organised by module. This workflow, which combines the red-green-refactor rhythm with regular full runs, is sufficient for most small projects, and it scales surprisingly well, because it is built on small, verified steps rather than on large, unverified leaps. The key is to follow it every time, not just when you remember.
Starting a project with tests is easier than adding them later, because the code is still small and the behaviour is still fresh in your mind. When you start a new module, create its test file at the same time, even if it contains only one test, and run it to confirm the setup works. That early setup prevents the common problem of a project growing without tests, until adding tests feels like a large, daunting task that is always postponed. If you inherit a project without tests, start by adding tests for the most important behaviour, or for the area you are about to change, rather than trying to test everything at once. Incremental coverage, focused on important behaviour and on areas under active change, is far more achievable than a sudden effort to reach complete coverage, and it delivers value quickly.
Running tests should be as easy as possible. Learn the single command that runs your suite, and use it often, after every small change, not just at the end of a session. If the suite is fast, running it becomes a reflex, like saving a file. If it is slow, investigate why, and consider splitting slower tests into a separate group, as discussed earlier, so that the main suite stays fast. Keep the test command visible, in a short note or a script, so that you do not need to remember its exact form. Friction, even a small amount, reduces how often tests are run, and frequency is what makes testing effective. Remove friction wherever you can, and testing will happen naturally.
Finally, review your tests periodically, just as you review your code. Delete tests that no longer check meaningful behaviour, update names that no longer match behaviour, and add tests for bugs you fixed, as regression tests. A small project's test suite should stay lean and clear, growing with the program but not accumulating clutter. A periodic review, perhaps when you finish a feature, is enough to keep the suite healthy. The workflow is therefore not just about writing tests, but about maintaining them as a living part of the project, alongside the code they protect. With a simple, consistent workflow, even a solo developer on a small project can enjoy the confidence that testing provides, without the overhead of a complex process.
Check: What simple four-step workflow can a small project use to make testing a consistent habit?
Section 47: Common Testing Mistakes
Beginners make a handful of testing mistakes so consistently that recognising them in advance can save a great deal of time. The first is testing too little, writing only one happy-path test for each function and assuming that is sufficient. A single test can pass while the function fails on edges, errors, and boundaries, giving false confidence that is worse than no confidence, because it discourages further checking. The remedy is to think in cases, not in single examples, and to write a small family of tests for each function, covering typical, edge, error, and boundary inputs. A function with five thoughtful tests is far better protected than a function with one, and the extra tests take only a few minutes to write when the behaviour is still fresh.
The second mistake is testing too much of the wrong thing, such as testing library functions, internal details, or trivial code, as discussed in the section on what not to test. These tests add maintenance cost and brittleness without adding confidence, and they make the suite slower and harder to read. The remedy is to ask, for each test, whether a user or a caller would care if the behaviour it checks broke. If the answer is no, the test is probably low-value and can be removed or simplified. Testing is an investment, and like any investment, it should be directed where the return is highest: important behaviour, tricky logic, and past bugs, not incidental details.
The third mistake is writing tests that are hard to read, with vague names, complex setup, and assertions that are difficult to interpret. Such tests may pass, but they fail as documentation, and they are hard to maintain, because a future reader cannot tell what they check without significant effort. The remedy is to prioritise readability: descriptive names, arrange-act-assert structure, simple assertions, and helpers only where they genuinely clarify. A test that is easy to read is easy to trust, easy to fix when it fails, and easy to update when behaviour changes. Readability is not cosmetic; it is what makes a suite sustainable over time.
The fourth mistake is ignoring failures, or treating them as noise. A failing test that is dismissed without investigation, or a suite that is routinely run with known failures, loses its power as a signal, because developers learn that failures do not necessarily mean something is wrong. The remedy is to treat every failure as a question to answer promptly, and to fix flaky or broken tests quickly, rather than letting them accumulate. A suite that is always green, except when it genuinely finds a problem, is a suite that developers trust and run often. Protecting that trust requires discipline, but the payoff, early detection of real bugs, is worth it. Avoiding these four mistakes, testing enough of the right things, readably, and responding to failures, covers most of what makes testing effective in practice.
Check: What are four common testing mistakes, and what is the remedy for each?
Section 48: Testing and Debugging Together
Testing and debugging are often taught as separate skills, but in practice they form a single loop: tests reveal failures, debugging explains them, fixes resolve them, and tests confirm the resolution. A failing test is the starting point for debugging, because it provides a reproducible case, a clear expected outcome, and a precise location where the mismatch was observed. That information is far more useful than a vague report that something seems wrong, because it lets you reproduce the problem reliably and check your fix objectively. Without a test, debugging often involves guessing at inputs, manually inspecting output, and hoping the fix worked. With a test, debugging becomes systematic: reproduce, investigate, fix, verify, and the test remains as a guard against recurrence.
Debugging with tests also encourages a hypothesis-driven approach. When a test fails, you form a hypothesis about the cause, based on the failure message and the pattern of the mismatch, and then you test that hypothesis, by reading the relevant code, by simplifying the input, or by adding temporary investigation, such as printing intermediate values. If the hypothesis is confirmed, you fix the cause. If it is refuted, you form a new hypothesis. This loop is more efficient than random changes, because each step is guided by evidence, and the test provides a clear criterion for success: the test passes, and the rest of the suite still passes. Random changes, by contrast, may make a test pass for the wrong reason, or break another test, without revealing the true cause.
Tests also help debugging by narrowing the search space. A unit test failure points at a small function, while a workflow test failure points at an interaction, and the distinction guides where to look first. A suite with many small tests acts like a set of sensors, each monitoring one behaviour, so that a failure localises the problem quickly. A program without tests, or with only broad tests, requires a much wider search, because a failure could be anywhere. This localisation is one of the most practical benefits of testing, and it is often overlooked by beginners who think of tests only as a final check. Tests are diagnostic tools, not just verdicts, and their value is highest during debugging, when precise information saves the most time.
Finally, the testing-debugging loop improves code over time. Each bug found by a test, investigated through debugging, and fixed with a regression test leaves the program stronger and better understood. The regression test captures the bug's essence, the debugging process reveals the code's weaknesses, and the fix addresses the cause, not just the symptom. Over many cycles, this loop produces code that is not only correct for the cases tested, but also better structured, because debugging often reveals design flaws, such as hidden dependencies or unclear responsibilities, that can then be addressed. Testing and debugging are therefore not separate chores, but two halves of a single discipline: finding out what is wrong, understanding why, and ensuring it stays fixed.
Check: How does a failing test make debugging more systematic than a vague report that something seems wrong?
Section 49: Reviewing Your Own Tests
Reviewing your own tests is a skill distinct from writing them, because writing focuses on making tests pass, while reviewing focuses on whether tests are good. A useful review starts by reading each test as a reader, not as its author, asking whether its name clearly states the behaviour, whether its arrangement is easy to understand, whether its assertion is strong enough to catch a real bug, and whether it checks a behaviour that matters. Tests that fail this reading test, because their names are vague, their setup is confusing, or their assertions are weak, should be revised, even if they pass. Passing is not sufficient; a test must also be clear, strong, and meaningful, or it will mislead future readers and fail to protect the code when it matters.
A second review question is coverage of important behaviour. List the behaviours a function or module should have, including edges, errors, and boundaries, and check that each behaviour has at least one test. Gaps in this list are gaps in protection, and they should be filled before the code is considered complete. Coverage tools can help identify uncovered lines, but a manual behaviour list is often more informative, because it focuses on what the program should do, rather than on which lines ran. A behaviour that has no test, even if its lines were executed incidentally by another test, is not protected, because no test asserts its outcome. Reviewing against a behaviour list catches that distinction, which coverage alone may miss.
A third review question is independence and reliability. Can each test run alone, in any order, and produce the same result? Does any test depend on a file, a service, a time, or a random value that could make it flaky? Does any test share mutable state with another test? Answering these questions requires running tests in isolation, in different orders, and repeatedly, to check for flakiness and dependence. A test that passes in the full suite but fails alone, or that passes usually but fails occasionally, needs attention, because it undermines trust in the suite. Reviewing for independence is not a one-time task; it should be repeated whenever tests are added or changed, because dependence can creep in gradually through shared fixtures or helpers.
Finally, review tests for duplication and clutter. Are several tests checking the same behaviour with slightly different data, where parameterisation would be clearer? Are there tests for trivial code, library functions, or internal details that add no value? Are there tests that are commented out, skipped without explanation, or left over from an earlier design? Cleaning up such clutter keeps the suite lean and trustworthy, and it makes future reviews easier, because there is less noise to sift through. A periodic self-review, perhaps after finishing a feature, is a small investment that pays off in a suite that remains a pleasure to run and a reliable guide to the program's behaviour.
Check: When reviewing your own tests, what four questions should you ask about each test and the suite as a whole?
Section 50: A Testing Mindset to Keep
A testing mindset is not a set of tools or a checklist, though tools and checklists help. It is a habit of mind: the habit of asking, for every piece of code you write, how you would know if it were wrong, and what example would reveal the mistake. That question, asked early and often, changes how you write code, because code written with its tests in mind tends to be simpler, more modular, and more explicit about its assumptions. Functions become smaller, because small functions are easier to test. Dependencies become parameters, because parameters are easier to replace with doubles. Edge cases become decisions, because tests force you to decide what should happen at boundaries. Testing, practised as a mindset, improves design, not just verification, and that improvement compounds over a career.
The mindset also includes humility about your own code. Every developer, no matter how experienced, writes bugs, and the testing mindset accepts that fact without embarrassment, treating tests as a normal part of writing, not as an admission of weakness. A developer who tests is not saying they do not trust themselves; they are saying they understand that complex behaviour is hard to get right first time, and that a second check, in the form of a test, is a sensible precaution. That humility extends to tests themselves, which can also be wrong, and which should be reviewed, questioned, and improved, just like code. A testing mindset is therefore a mindset of evidence: claims about behaviour should be backed by runnable examples, not just by confidence or by a single manual run.
Another part of the mindset is patience with the process. Writing tests takes time, and that time can feel like a delay when you are eager to finish a feature. The patience comes from experience: the time spent writing tests is usually repaid, many times over, in time saved debugging, in confidence when refactoring, and in trust from users and teammates who rely on the program. A developer who has felt the pain of a bug that a test would have caught, or the relief of a test catching a mistake before it reached users, understands that patience is not wasted time, but invested time. That understanding grows with each project, and it is why experienced developers test even when they are in a hurry, especially when they are in a hurry, because haste is when mistakes are most likely.
Keep this mindset as you move to the next chapters, where testing will meet debugging, logging, environments, and collaboration. The specific tools will change, and the scale will grow, but the core question remains: how would you know if this were wrong, and what example would prove it right? Ask that question for every function, every module, and every feature, write the test that answers it, and run the tests often. Over time, the habit will become automatic, and you will find that programs you test are not only more reliable, but also clearer, simpler, and more enjoyable to write. That is the real reward of testing: not a green suite, though that is satisfying, but the confidence and clarity that come from knowing, with evidence, that your code does what you intended.
Check: What is the core question of a testing mindset, and how does asking it early improve code design?
Practical Task
Choose a small module you have already written (or write a new one with a task-list manager: add, complete, and list functions), then write a full test suite for it: unit tests for each function, edge and error tests, one workflow test that adds, lists, and completes, and one regression test for a bug you deliberately introduce and fix. Run the full suite, then run only the tests for one function, and note the difference in feedback speed.
Chapter Questions
- How do module and workflow tests complement unit tests, and why does a suite need both?
- Explain regression testing: how do you turn a bug report into a permanent test?
- Why is high test coverage not the same as good testing, and how should coverage be used?
- What should you not test, and why do low-value or environment-dependent tests harm a suite?
- How does property-based thinking improve on testing with single examples alone?