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.

Frequently Asked Questions

How many tests should I write for one function? Count the distinct behaviours, not the lines. A typical function needs one test for the normal case, one for each rule it enforces, and one per edge such as empty input, zero or a boundary value. Three to six is common for a small function. If you need fifteen, the function is probably doing several jobs and should be split.
Should I write tests before the code? Test-driven development helps most when the requirement is clear and the logic is fiddly, like a discount rule or a parser, because writing the assertion first forces you to decide the interface. It helps least when you are exploring an unfamiliar API and the shape keeps changing. Writing the test immediately after the code, while the edge cases are still fresh, captures most of the benefit.
What is the difference between a unit test and an integration test? A unit test exercises one piece of your logic with its boundaries replaced, so it runs in milliseconds and fails for one reason. An integration test runs several real pieces together, often with a real database, to prove the wiring is correct. You want many fast unit tests and a smaller number of integration tests over the critical flows such as signup, checkout and login.
My tests pass locally but fail on CI. What is going wrong? Almost always hidden state. Common culprits are tests that depend on run order because one leaves data behind, reliance on a local database or an environment variable that only exists on your machine, an unmocked network call, and time zones since build servers usually run in UTC while your laptop is on IST. Run the suite in a random order once to expose order dependence.
Do interviewers actually ask about testing? For fresher roles they rarely ask you to recite definitions, but they very often ask how you knew your project worked. Being able to open a repository and show a test file with clear names, a mocked API call and a case for an edge condition separates you instantly from candidates who only have screenshots. It also reads as production experience, which is what service and product companies are screening for.