What you'll learn
Quick Answer
Test the logic you wrote, not the framework. Structure every test as arrange, act, assert, and name it after the behaviour it protects. Mock only at the boundary of your system such as network, clock and filesystem, and patch the name in the module that uses it. Assert on what a caller can observe, never on which private helper ran. Treat coverage as a map of untested files, not a number to hit.
What is actually worth testing
Most people write their first test against the wrong thing. They check that a getter returns what the setter stored, or that the ORM really did save a row. Neither tells you anything, because you did not write that code. The framework authors already tested it, and if it broke, thousands of projects would break with you.
Test the logic you wrote: a calculation, a branch, a validation rule, a transformation. A quick filter that works well in practice: if a teammate could break the function by typing a slightly wrong number, it deserves a test. If the only way to break it is to uninstall Python, it does not.
Then push on the edges, because that is where bugs live. Zero. An empty list. A negative amount. A duplicate submit. The boundary itself. If the rule is orders above 500 rupees get free delivery, write cases for 499, 500 and 501 rather than for 1000, because the off-by-one is the only interesting part.
# billing.py
def gst_total(amount, rate=0.18):
if amount < 0:
raise ValueError("amount cannot be negative")
return round(amount * (1 + rate), 2)
# test_billing.py
import pytest
from billing import gst_total
def test_adds_18_percent_gst_by_default():
assert gst_total(1000) == 1180.00
def test_rate_can_be_overridden():
assert gst_total(1000, rate=0.05) == 1050.00
def test_rejects_negative_amount():
with pytest.raises(ValueError):
gst_total(-1)
The failure mode nobody warns you about is money in floats. 0.1 + 0.2 is not 0.3 in any language using IEEE 754 doubles, which is most of them. Here round() hides the drift, but once you sum a few hundred line items the paise wander and a test written as assert total == 4999.99 starts failing for reasons unrelated to your logic. Store money as integer paise or use decimal.Decimal, and when you genuinely must compare floats, use pytest.approx instead of ==.
Arrange, act, assert and naming tests
Every readable unit test has three parts. Arrange builds the inputs. Act calls the one thing under test. Assert checks the result. Keeping them visually separate means that when the test fails on a build server at 2am, whoever opens it can understand the intent in five seconds without reading the source file.
The rule that matters most is one act per test. If a test calls three functions and then checks four things, a failure tells you the block is broken but not which part. Two small tests that each fail for exactly one reason are worth far more than one big test that fails for six.
// cart.js
export function cartTotal(items, coupon = null) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const discounted = coupon ? Math.max(subtotal - coupon.off, 0) : subtotal;
return Math.round(discounted * 1.18);
}
// cart.test.js
import { cartTotal } from "./cart";
test("applies the coupon before GST", () => {
// arrange
const items = [{ price: 500, qty: 2 }];
const coupon = { code: "FIRST100", off: 100 };
// act
const total = cartTotal(items, coupon);
// assert
expect(total).toBe(1062); // (1000 - 100) * 1.18
});
test("never returns a negative total", () => {
const total = cartTotal([{ price: 50, qty: 1 }], { code: "BIG", off: 500 });
expect(total).toBe(0);
});
Name the test after the behaviour, not the function. test_cart_total_1 tells a future reader nothing; applies the coupon before GST tells them the business rule and, when it fails, tells them which rule broke. In Python the common shape is test_<unit>_<condition>_<expected>, for example test_gst_total_rejects_negative_amount. Long names are fine. Nobody calls a test by hand.
One more habit worth building early: no if statements inside tests. A conditional means the test does different work depending on state, so a green result no longer proves which path ran. Write two tests instead.
Test behaviour, not implementation
This is the single idea that separates a test suite people trust from one they delete after six months. A test should describe what the code promises to callers, not how it currently keeps that promise. The moment you assert on internals, refactoring becomes expensive and the suite starts lying to you in both directions.
// fragile: asserts how the work is done
test("total uses applyGst", () => {
const spy = jest.spyOn(pricing, "applyGst");
pricing.cartTotal([{ price: 100, qty: 1 }]);
expect(spy).toHaveBeenCalled();
});
Rename applyGst to addTax and this test goes red although behaviour did not change by a single rupee. That is the annoying direction. The dangerous direction is worse: introduce a bug so applyGst returns zero, and the test still passes, because it only ever checked that the function was called.
Assert on what a caller can observe. The returned value. The exception raised. The row that ended up in the database. The email pushed onto the queue. Those are the promises. Private helpers, loop counters, intermediate variables and the order in which you call your own methods are implementation details you should be free to change on a Tuesday afternoon.
There is one honest exception. Sometimes the side effect is the behaviour. "Do not charge the customer twice" cannot be checked from a return value, so asserting that the payment client was called exactly once is legitimate, because that call is the contract. The test to reach for there is expect(paymentClient.charge).toHaveBeenCalledTimes(1), and it is meaningful precisely because a second call would cost a real person real money.
A useful self-check before you commit a test: if I rewrote the body of this function completely but kept the same inputs and outputs, should this test still pass? If the answer is yes and it would not, the test is testing the wrong layer.
Mocking without faking away the test
Mock the boundary of your system, nothing inside it. Boundaries are the things that are slow, flaky, or that cost money and cannot be undone: HTTP calls, the database in a pure unit test, the filesystem, the clock, randomness, payment gateways, SMS providers. Your own pricing function is not a boundary, and mocking it means your test now checks your mock configuration rather than your code.
# service.py
import requests
def district_for_pincode(pin):
r = requests.get(f"https://postal.example.com/pin/{pin}", timeout=5)
r.raise_for_status()
return r.json()["district"]
# test_service.py
from unittest.mock import patch, Mock
import service
@patch("service.requests.get")
def test_returns_district_from_api(mock_get):
mock_get.return_value = Mock(
raise_for_status=Mock(return_value=None),
json=Mock(return_value={"district": "Pune"}),
)
assert service.district_for_pincode("411001") == "Pune"
mock_get.assert_called_once()
Notice the patch target. The rule is patch the name in the module that uses it. Here service.py does import requests, so it looks up the attribute at call time and either target works. But if it had used from requests import get, the module would already hold its own reference and patching requests.get would do nothing at all. Your test would then hit the real network, pass on your laptop, and fail on a build machine with no internet. This is easily the most common wasted hour in Python testing.
The clock deserves the same treatment. Code that calls datetime.now() internally produces tests that pass today and fail on the 1st of the month or the day the token expires. Pass time in instead, which is simpler than any mocking library:
def is_expired(token, now=None):
now = now or datetime.now(timezone.utc)
return token.expires_at < now
Now a test can hand it any instant it likes. The same trick works for random values, generated IDs and the current user. Anything you inject is anything you never have to patch.
Coverage is a signal, not a target
Coverage tools record which lines executed while the tests ran. That is all they record. They do not know whether you asserted anything, and this gap is why a team can carry a proud 90 percent badge and still ship broken releases.
pytest --cov=app --cov-branch --cov-report=term-missing
npx jest --coverage
Consider the difference between these two tests. Both give the same coverage number for gst_total:
def test_it_does_not_crash():
gst_total(1000) # covered, proves nothing
def test_adds_gst():
assert gst_total(1000) == 1180.00 # covered, proves something
Turn on branch coverage (--cov-branch in pytest, on by default in most JavaScript runners) because plain line coverage will mark an if as covered when only the true path ever ran. The term-missing report is the genuinely useful output: it lists the exact line numbers no test has touched, and reading that list usually surfaces a forgotten error branch within a minute.
Use the number the way you would use a map, not a scoreboard. A file sitting at zero percent is a real finding, especially if it handles payments or authentication. Moving the overall figure from 88 to 95 by writing tests for __repr__ and auto-generated getters is theatre, and it makes every future refactor slower because those useless tests still have to be updated.
If you want an honest measure of whether your suite catches bugs, try mutation testing. Tools such as mutmut for Python and Stryker for JavaScript deliberately corrupt your code, flipping a < to a <= or returning a constant, then re-run the suite. Every mutation that survives is a change your tests failed to notice. It is slow to run and humbling the first time, which is exactly why it is worth doing once on the module that handles money.
