Testing is how you find out, in advance, whether your code does what you think it does. Without tests, the only way to know if a change broke something is to run the program by hand, click around, and hope you covered every path. With tests, you write the expectations once and let the machine re-check them every time you change a line. This lesson covers the ideas behind testing: why it matters, what kinds of tests there are, and what makes a good test.
A working program at one point in time is not the same as a program that stays correct as it changes. The moment you add a feature, fix a bug, upgrade a library, or rename a function, something else in the code might quietly break. Testing is the discipline that catches those breakages early, ideally seconds after you cause them.
Four concrete reasons to write tests.
Confidence to refactor. Code you're afraid to touch is dead weight. If you don't know whether changing a function will break the checkout flow, you leave the function alone. Tests reverse that fear: you change the code, run the tests, and either they pass (you're done) or they fail (you know exactly what broke). Refactoring becomes routine instead of risky.
Catching regressions. A regression is a bug that re-appears after being fixed, or a new bug introduced by an unrelated change. Tests catch them because the test for the original bug is still running. The first time you fix "discount went negative when the cart was empty," you also write a test that asserts the discount is zero on an empty cart. Six months later, when someone reworks the discount engine, that test fails the instant the bug comes back.
An executable specification. Tests describe what the code is supposed to do, in code. A test named test_ten_percent_discount_on_hundred_dollar_cart_returns_ninety is more precise than a wiki page, and it can't drift out of date because if it does, it fails. New developers reading the test suite learn the system faster than they would by reading the implementation, because the tests show intent: inputs in, expected outputs out.
Faster debugging. When you find a bug, the first thing to do (before fixing it) is write a test that reproduces it. Now you have a one-keystroke way to check if your fix works, and the test stays in the suite forever as protection against the same bug coming back. Without that test, debugging becomes a manual loop: change code, restart the program, click through to the broken screen, repeat. With it, the loop is one command and a second of waiting.
Here's a tiny taste of what a test looks like, using only assert:
That's it. A test is a small program that calls the code you wrote and checks the answer. The assertion is the part that decides pass or fail. Everything we're about to cover (frameworks, fixtures, mocking, coverage) is machinery built on top of this same idea. There's a secondary benefit worth calling out: tests change how you write code. When you know a function will be tested, you tend to give it a clear input and a clear output, fewer side effects, and a smaller surface area. Code that's easy to test is usually code that's easy to read, easy to reuse, and easy to debug. Writing the test first (or just thinking about how you'd test the function before you finish it) nudges the design in a good direction even before you run the test.
A separate angle is what happens to the team, not just the code. When a junior developer joins, the test suite is the fastest way for them to start contributing. They make a change, run the tests, and find out within seconds whether they've broken anything. Without tests, they're afraid to touch anything, ask senior developers to review every line, and learn the codebase slowly. With tests, they can experiment, see immediate feedback, and build a mental model from the test names alone. Onboarding time drops measurably in well-tested projects.
Tests come in different sizes, and the size matters. A test that exercises one function in isolation is fundamentally different from one that spins up your whole application and clicks through the checkout page. Both are useful, but for different reasons. The community has settled on three broad categories.
Unit tests check one small piece of code in isolation. "One small piece" usually means one function, one method, or one class. A unit test for apply_discount(100, 10) calls that function with those exact arguments and asserts the result is 90. It doesn't talk to a database, doesn't open a file, doesn't make a network call. If a unit test fails, you know exactly which function broke, because the test only exercised one.
Integration tests check that two or more pieces work together. The pieces might be functions in your own code, or your code talking to a database, or your code calling an external library. The defining feature is that the test exercises real connections between components, not just one component on its own. An integration test for a cart system might create a real cart, add real items, write the cart to a real SQLite database, and read it back to check the totals matched.
End-to-end tests (also called E2E or system tests) check the whole application from the outside. They simulate a user: open the homepage, click "add to cart," fill in checkout, submit the order. They exercise every layer (UI, backend, database, external services) the way a real customer would. If an E2E test passes, you have strong evidence the entire flow works. If it fails, the cause could be in any of those layers, which is why diagnosing E2E failures takes longer.
Here's a side-by-side comparison.
| Kind | What it tests | Speed | Stability | When it catches a bug |
|---|---|---|---|---|
| Unit | One function or class, isolated | Very fast (milliseconds) | Very stable | Immediately, exact location |
| Integration | Two or more components together | Slower (hundreds of ms) | Mostly stable | Soon, area is known |
| End-to-end | The full app, like a user would | Slow (seconds to minutes) | Often flaky | Late, root cause unclear |
The three sizes are complementary, not competing. Unit tests give you fast feedback and precise failure messages. Integration tests catch bugs that only appear when pieces meet (a function returns None in a case the caller didn't expect, two modules disagree on the format of a dictionary, the SQL query reads back a different shape than what was written). End-to-end tests catch bugs no smaller test could see: the buttons on the page are wired to the wrong handlers, the checkout submit silently swallows errors, the production config disables a feature the dev config enables.
A small e-commerce illustration of all three.
The unit test calls apply_discount with two values, checks the answer, done. It would still pass if the entire rest of your application were on fire.
This one calls cart_total, which in turn calls apply_discount. If either function has a bug that only shows up when they're combined (a unit conversion error, a wrong rounding rule), this test catches it where the unit tests for each function might not have.
End-to-end is harder to show in a script because it usually involves a real browser or HTTP client driving a real server. The shape is "start the application, hit the homepage URL, simulate clicks, assert the resulting page contains the expected text." Tools like selenium, playwright, and requests against a running server are the typical way. We won't write one here; the point is just to recognize it as the largest, slowest, most realistic kind of test.
Cost: A unit test typically runs in under a millisecond. An integration test runs in tens to hundreds of milliseconds, depending on what it touches. An end-to-end test runs in seconds to minutes per case. If your suite is mostly E2E tests, your feedback loop is minutes instead of seconds, and developers stop running it.
The boundaries between the three kinds aren't always sharp. A test that calls one function which happens to call a helper function in the same module looks like a unit test to most people, even though it technically exercises two functions. A test that uses an in-memory SQLite database instead of a real PostgreSQL server is sometimes called a unit test (because it's fast and isolated) and sometimes an integration test (because it talks to a database). Don't get too hung up on the labels. The useful question is: how isolated is the test, how fast is it, and what does it tell you when it fails. Those three answers are more important than which bucket you put it in.
One more practical note. Different teams draw the line in different places. Some call any test that touches the file system an "integration test," even if it's only writing to a temp file. Others call it a "unit test" because no external service is involved. If you join a new project, ask early what the convention is so the words mean the same thing to everyone.
Given that the three kinds of tests have different speeds and different failure modes, what mix should you aim for? The widely accepted answer is the test pyramid: many unit tests at the bottom, fewer integration tests in the middle, very few end-to-end tests at the top.
The pyramid shape captures a trade-off. Unit tests are cheap to write and cheap to run, so you can afford lots of them, and they give you the fastest feedback. End-to-end tests are expensive on both axes, so you keep them rare and reserve them for the most important user flows (logging in, adding to cart, completing checkout). Integration tests sit in the middle: enough of them to catch the seams between components, not so many that the suite grinds to a halt.
A rough guideline: for every end-to-end test, you might have ten integration tests, and for every integration test, ten unit tests. That's not a law; it depends on the codebase. The principle is what matters: the bulk of your testing effort should be on small, fast tests, with progressively fewer tests as you go up the pyramid.
The opposite shape, the ice-cream cone (lots of E2E, few unit tests), is a well-known anti-pattern. Teams end up there when they don't trust unit tests ("they don't prove the app actually works") and over-invest in E2E ("at least these click through real screens"). The result is a slow, flaky suite that nobody wants to run, and a codebase where regressions slip through because the slow tests aren't run on every change. There's a related shape called the testing trophy, which some teams prefer for web applications: static analysis (typechecks, linters) at the base, then a heavier band of integration tests, then a smaller band of unit tests, and finally a few E2E tests at the top. The argument is that for code which is mostly glue between frameworks and databases, integration tests give better value per test than tiny unit tests of individual functions. The pyramid is still the safer default for general Python code; the trophy is worth knowing about so you recognize the argument when you see it.
The reason the pyramid keeps winning in practice is that it matches how bugs actually distribute. Most bugs in real code are logic errors in one function: an off-by-one, a missing edge case, a wrong formula. Unit tests catch those cheaply. Fewer bugs sit at component boundaries (a function returns a dict where the caller expected a list), and integration tests catch those. The rarest class is a bug that only shows up when every layer of the system is wired together, and that's what E2E tests are for. Distributing test count by bug frequency gives you the pyramid.
A test is a small program, and like any program it's easier to read when it has a consistent structure. The most common structure (popular enough that it has a name) is AAA: Arrange, Act, Assert. Three sections, in that order, separated by a blank line.
Here's a test with the three sections labeled.
Three things, in order. The Arrange section sets up the inputs. The Act section is one line: the call to the function being tested. The Assert section is one line: the check on the result. You don't actually have to write the comments; once you've seen the pattern, the blank lines do the work. The structure becomes a habit.
The pattern matters because it forces you to think clearly. If you can't separate setup from the call from the check, your test is doing too much or your code is doing too much. A common smell is a test where the "act" step is three or four calls (because the function under test needs a bunch of related calls to do anything useful). That's usually a sign the function should be split, or the test should focus on a smaller piece.
A slightly larger example with a cart.
The name of the test tells you what it's checking before you read a single line of code: cart_total_with_three_items_and_ten_percent_off. Combined with the AAA structure, the body is almost redundant: arrange three items and a 10% discount, act by calling cart_total, assert the result is the expected value.
Some teams write a fourth A, Annotate or Arrange-Act-Assert-Annotate, where the assertion message documents what was expected and why. Whether you do that with a comment, with the message argument to assert, or with a descriptive test name is a style choice. The important part is that anyone reading the test can immediately tell what's being tested and what answer was expected.
Another variant of the same idea goes by the name Given-When-Then and comes from the behavior-driven testing community. The mapping is direct: Given is Arrange, When is Act, Then is Assert. Some teams write tests that read almost like English by using those words explicitly:
The mechanics are identical to AAA; the comments are just phrased differently. Pick whichever vocabulary feels more natural for your team. The structure (three labeled sections in a specific order) is the part that matters, not which set of three words you use.
One pitfall with AAA: it's tempting to add a fourth section, "Cleanup," at the bottom of every test. Most of the time you don't need one, because the test's local variables go out of scope when the function returns and the garbage collector handles the rest. When you do need cleanup (closing a file, dropping a table, removing a temporary directory), put it in the framework's teardown hook or a pytest fixture, not at the bottom of the test. The reason is that a try/except or an early return could skip the cleanup line, leaving you with a leaked resource. Fixtures use Python's exception handling to guarantee teardown runs no matter what; manual cleanup at the bottom doesn't.
Not every test is a good test. A test can pass and still be misleading, fragile, or slow enough to discourage running the suite. The community has converged on a handful of properties that distinguish good tests from bad ones. Some authors use the acronym FIRST (Fast, Independent, Repeatable, Self-validating, Timely). We'll touch on the same ideas here without leaning on the acronym.
Fast. A test should run in milliseconds, ideally microseconds. If a single unit test takes a tenth of a second, a suite of 1,000 of them takes nearly two minutes, which is too long to run on every save. Speed comes from isolating the test from slow things: no disk I/O, no network calls, no real databases. A test that imports a 200MB machine-learning model into memory just to check a string parser is not fast, and the slowness will spread.
Isolated. A test should not depend on, or interfere with, any other test. Running test_a before test_b should give the same result as running them in the opposite order, or running only one of them. Tests that share state through global variables, files on disk, or rows in a shared database are not isolated; they pass or fail based on what else ran first, which makes failures impossible to diagnose. The cure is to give each test its own data and clean up after itself.
Deterministic. A test should produce the same result every time, given the same code. Tests that depend on the current time, on random numbers, on the order of items in a dictionary (before Python 3.7), or on the response time of a remote server are non-deterministic. They pass on Tuesday, fail on Wednesday for no apparent reason, and erode trust in the suite. Fix non-determinism by controlling the inputs: pass in the time, seed the random generator, mock the network call.
Readable. A test is documentation. Someone reading the test six months from now should be able to tell, at a glance, what the test is checking and what answer it expects. The AAA structure helps. So do descriptive names (test_empty_cart_returns_zero beats test_1), explicit expected values (assert total == 49.47 beats assert total == expected_total, where expected_total is computed three lines up using the same formula as the code), and a single concept per test.
One thing per test. Each test should check one specific behavior. If you write a test that calls five functions and makes ten assertions, the failure message tells you something is wrong, but not which something. Worse, the first failing assertion stops the test, so the other nine don't even run. Split such tests into separate tests, each named for the behavior it checks. The suite gets bigger, but each failure pinpoints the bug.
The shape below summarizes the five properties together.
A test that hits four out of five is still useful. A test that hits zero out of five is worse than no test: it gives false confidence when it passes and offers no help when it fails. As you write tests over the next few chapters, these properties are the lens to check your work against.
A few common anti-patterns are worth recognizing now, because they undermine these properties:
If a test you write or inherit shows one of these patterns, treat it as a bug in the test itself, not as a quirk of the suite. Fixing test bugs is part of writing tests.
Cost: A test suite that takes more than a few seconds to run becomes a chore. Developers run it less often, bugs slip in between runs, and the safety net rots. The single most important property of a healthy suite is that it stays fast. If a test is slow, ask whether it really needs to do what makes it slow, or whether a smaller test could catch the same problem.
assertThe simplest way to write a test in Python uses only what's already in the language. You write a function whose body calls the code under test and uses assert to check the result.
Three tests, three functions, called in order. If any assertion fails, the script stops with AssertionError and the others don't run. This is, technically, testing.
It's also pretty miserable to do at any scale. A few things you'll quickly miss.
Better error messages. When assert cart_total([19.99]) == 19.99 fails, plain Python just says AssertionError. You don't know what cart_total([19.99]) actually returned. To get a useful message, you have to type it yourself: assert cart_total([19.99]) == 19.99, f"expected 19.99, got {cart_total([19.99])}". Test frameworks introspect the expression and show both sides automatically.
Test discovery. With three tests you can call each one by hand. With three hundred, you need a tool that finds every function starting with test_ in every file in the project and runs them, without you maintaining a manual list. Frameworks do this for you.
Setup and teardown. Many tests need a fresh cart, or a clean database, or a temporary directory. Doing that in plain Python means duplicating the setup at the top of every test and the cleanup at the bottom. Frameworks provide fixtures: reusable, named setups you can pull into any test by declaring it as a parameter.
Reporting. When all tests pass, you want to see a green count: 42 passed. When some fail, you want to see which, where, and what each failure looked like, with line numbers and diffs. You don't want to scroll through a wall of tracebacks. Frameworks aggregate results, color them, and summarize.
Stopping on first failure (or not). The plain-Python version stops the moment any assertion fails. That's sometimes what you want (fail fast) and sometimes not (run everything, report all the failures at once). Frameworks make this configurable with a flag.
Slow vs. fast modes. Frameworks let you mark tests as slow and skip them by default, run only tests matching a name pattern, re-run only the tests that failed last time, and so on. None of that exists in plain Python.
To make the gap concrete, here's what a plain-assert failure looks like compared to what a framework would show.
Plain Python:
That's it. You know an assertion failed, but not which one (if you had several on the same line), not what the values were, and not which side was wrong. To get more, you have to write the message yourself: assert total == 49.47, f"got {total}".
A framework like pytest rewrites the assertion at import time and would print something like:
The actual value (49.5), the expected value (49.47), and the line number all appear automatically, without you writing a message. For multi-line comparisons (lists, dicts, strings), pytest even prints a diff. That's the kind of quality-of-life improvement that turns testing from a chore into a fast, reliable feedback loop.
The takeaway is that assert is the right building block, and Python has it. What's missing is the framework around it. Frameworks like unittest (in the standard library) and pytest (the most popular community choice) fill the gap. Both are built on top of the same assert idea you just saw.
Tests are functions, and like all functions they need names. Test names follow a few conventions worth knowing now, because they're the same in unittest, pytest, and most other testing tools.
Start with `test_`. Test functions are conventionally named test_<what_is_being_tested>. Both unittest and pytest discover tests by looking for functions whose names start with test_. A function called verify_cart_total is invisible to the test runner; rename it to test_verify_cart_total (or, better, test_cart_total_sums_three_items) and it gets picked up.
Describe the behavior, not the function. A name like test_apply_discount tells you which function is being tested but not what's being checked. test_apply_discount_returns_zero_for_full_discount tells you both. The name should answer: "What scenario, with what expected outcome?"
Be specific about inputs and outputs. test_cart_total_for_empty_list_is_zero is better than test_empty_cart_total. test_discount_percent_above_one_hundred_raises_value_error is better than test_invalid_discount. The reader shouldn't have to open the test body to find out what the test is checking.
Long names are fine. Test names are read more often than they're typed, and they show up in failure reports. A test name of 60 characters is normal. A test name of 8 characters (test_foo) is almost always a code smell.
Some patterns that work well:
| Pattern | Example |
|---|---|
test_<function>_<scenario>_<outcome> | test_apply_discount_with_zero_percent_returns_full_price |
test_<scenario>_<outcome> | test_empty_cart_total_is_zero |
test_<function>_raises_<error>_when_<scenario> | test_charge_raises_value_error_when_amount_is_negative |
test_<function>_<scenario> | test_cart_total_with_mixed_quantities |
Test files follow a parallel convention: a module called cart.py is tested by test_cart.py. Both unittest and pytest look for files matching test_*.py or *_test.py, depending on configuration.
Two layout choices are common in Python projects. The first puts tests in a parallel tests/ directory at the project root, mirroring the package structure:
The second co-locates each test file next to the module it tests:
Both work. The separate tests/ directory keeps production code clean and makes it easy to ship without tests in a distribution package. Co-located tests are easier to discover when reading the code (the test for cart.py is right there next to it). Many large projects use the separate directory; many small projects use co-location. There's no universal right answer; pick one per project and be consistent.
A small worked example to close the chapter. We'll write a function, write three tests for it using the AAA pattern, give them descriptive names, and use plain assert. The same code rewritten with unittest or pytest will look very similar; only the framework noise changes.
Three tests, three different shapes of check. The first verifies an edge case (empty cart). The second verifies the happy path with realistic data. The third verifies error behavior: when given a bad input, the function should raise ValueError, and the error message should mention the offending value.
The error-check pattern (try/except/else: raise AssertionError) is awkward, and unittest cleans it up with assertRaises. The empty-cart and happy-path tests are already in good shape; they translate almost line-for-line to either framework.
A few things to notice. Each test is named for the scenario, not for the function. Each follows AAA: arrange the inputs, call the function, assert the result. Each test exercises one behavior; you can read the name and predict exactly what's being checked. If any one fails, the message tells you the value that came back. None of them depend on any other test. All three together run in well under a millisecond.
That's the foundation. Frameworks are conveniences on top of this; they make the syntax cleaner, the failure output richer, and the test runner more capable. The ideas (AAA, descriptive names, fast and isolated checks) stay the same.
There's one practical idea worth covering before the framework chapters: the feedback loop. The whole point of having tests is that you can run them often enough to catch problems while they're cheap to fix. The slower the loop, the less you run it, and the more bugs accumulate between runs.
A typical loop with a good test suite looks like this:
You write code, run the tests, and either move on (everything green) or fix what broke. The loop runs many times per hour. Each turn takes a few seconds at most, which is why suite speed matters: if a turn takes a minute, you don't take ten of them per hour, you take one.
The same loop shows up at three layers in real projects:
pytest-watch or entr) re-run tests automatically when you save a file. The loop is sub-second. Bugs caught here never reach the next layer.Each layer is wider and slower than the last. The point of having all three is that they catch different things: local catches the obvious, pre-commit catches "I forgot to run them," and CI catches "works on my machine, fails on Linux." A team that runs only the CI suite has the slowest possible loop and ships the most bugs. A team that runs all three has the fastest loop at every layer and ships the fewest.
A concrete number to anchor this. If your suite runs in 2 seconds, you can run it 30 times an hour without thinking about it. If it runs in 2 minutes, you might run it twice an hour, and only the second time after the change feels "done." If it runs in 20 minutes, you run it at the end of the day, or only in CI, and bugs sit uncaught for hours or days. The speed of the suite directly determines how fast you find bugs.
That's why every chapter from here on cares about test speed. pytest is fast. Fixtures avoid duplicating setup. Mocks avoid real network calls. Each technique exists because the cost of slow tests is real, measurable, and felt every single day.
A question every developer asks early: how do I know when I've written enough tests? There's no exact answer, but a few heuristics help.
One test per behavior, not per line of code. A function with one branch needs at least one test, often two (one for each branch). A function with three branches needs at least three. The unit isn't lines, it's distinct behaviors: each if, each error case, each edge case is a behavior worth its own test. "I wrote 100 lines, so I need 100 tests" is the wrong frame. "I wrote a function that handles four cases, so I need at least four tests" is the right frame.
Cover edge cases explicitly. For any function that takes a list, write a test for the empty list. For any function that takes a number, write tests for zero, for a negative number, for the largest value you expect, and for one beyond the largest if there's a limit. Edge cases are where bugs hide; the middle of the range usually works fine.
Cover error paths. Every place your code raises an exception deserves a test that triggers it. If add_to_cart(item, quantity=-1) raises ValueError, write a test that calls it with -1 and asserts the exception. This proves the validation works, and it locks in the message and the type so a later refactor doesn't quietly change the contract.
Don't test the language. A test that checks 1 + 1 == 2 is not a useful test. Neither is one that asserts a built-in function does what its documentation says. Your tests should cover your code, not Python's. The line is sometimes fuzzy (testing a thin wrapper around dict.get is silly; testing a function that builds a cart from a JSON file isn't), but the principle is clear: if the only thing that could break the test is Python itself, the test isn't pulling its weight.
Stop when an extra test wouldn't catch anything new. After you've covered the main behaviors, the edge cases, and the error paths, more tests usually duplicate coverage without adding value. A function with four behaviors and four well-chosen tests is well covered. Writing eight tests for the same function is twice the maintenance burden for the same protection. The goal is meaningful coverage, not maximum coverage.
There's a separate tool called code coverage that measures, line by line, what percentage of your code is exercised by the test suite. High coverage is a good sign but not a guarantee: you can have 100% line coverage and still miss obvious bugs because the test ran the line without asserting the result was right. Low coverage is more useful: if a critical function has 0% coverage, you know for certain it's untested. As a rough target, most teams aim for somewhere between 70% and 90% coverage on production code, with the understanding that the last 10% is often not worth the effort (defensive code that never fires, log statements, glue with no logic in it).
The honest answer to "how many tests is enough" is "enough that you can refactor with confidence and that a typical bug is caught by an existing test before it gets to production." That's a feel that develops with practice. Start with one test per behavior, add tests when you find a bug (write the failing test first, then fix the code), and trust the suite to grow with the codebase.
10 quizzes