19.12. Automated Testing
Table of Contents
Why Automate Testing?
Manual testing is fine for tiny scripts, but it quickly becomes painful for real applications. Every time you:
- Add a new feature
- Fix a bug
- Upgrade a dependency
- Refactor code
you should verify that old behavior still works. Doing this by hand is slow and error prone.
Automated testing means you write code that tests your code. Then a tool runs all tests automatically and tells you if anything broke.
Automated tests are:
- Repeatable: Run them as often as you want, always the same way.
- Fast: Hundreds of tests can run in seconds.
- Reliable: They do not forget steps or get tired.
- Measurable: You can track how many tests pass, how long they take, and coverage.
Automated tests do not remove the need for thinking. They help you catch mistakes, they do not automatically make your design good.
In a backend project, automated tests become the foundation of safe changes and safe deployments. They are usually required before you can use CI/CD pipelines and serious deployment workflows.
Types of Automated Tests in a Backend
You already met unit, integration, and API tests. Here we focus on how they look when automated.
Common automated test types:
| Type | Scope | Speed | Typical Tools (Python) |
|---|---|---|---|
| Unit tests | Single function or method | Fast | pytest, unittest |
| Integration | Multiple components together | Medium | pytest, test DB, test Redis |
| API tests | HTTP endpoints | Medium | pytest, FastAPI TestClient, etc |
| End-to-end | Whole system, like a user | Slow | pytest, HTTP clients, Postman |
| Regression | Any test guarding fixed bugs | Varies | Whatever framework you use |
In practice, you often start with unit and API tests. As the project grows, you add more integration and end-to-end tests.
A healthy backend test suite looks like a pyramid:
- Many small, fast unit tests at the bottom
- Fewer, but important, API and integration tests in the middle
- A small number of full end-to-end tests at the top
This is often called the test pyramid.
Organizing Tests in a Project
A typical Python backend project has a structure like:
myapp/
myapp/
__init__.py
main.py
models.py
services/
users.py
payments.py
tests/
__init__.py
test_users.py
test_payments.py
api/
test_auth_api.py
test_orders_api.py
integration/
test_db_integration.pyCommon patterns:
- Put all tests in a top-level
tests/folder. - Mirror your application structure:
myapp/services/users.pyhastests/test_users.py. - Use descriptive test file names:
test_auth_api.pyclearly tests authentication endpoints. - Group API and integration tests in subfolders if the suite grows.
You run tests from the project root:
pytest # discovers tests/ automatically
pytest tests/api
pytest tests/test_users.py::test_create_userWriting Useful Automated Tests
Automated tests return value only if they are maintainable and meaningful. There are a few simple habits that make a big difference.
Test Naming
Use clear, descriptive names. A common style is:
test_<what_is_being_tested>_<expected_behavior>
Examples:
def test_create_user_stores_hashed_password():
...
def test_login_rejects_invalid_password():
...
def test_get_task_returns_404_for_missing_task():
...From the name alone, you can guess what is broken if the test fails.
Given / When / Then Pattern
This structure keeps tests readable:
- Given: initial state or inputs
- When: the action being tested
- Then: assertions about the result
Example for a service function:
def test_calculate_total_with_discount():
# Given
items = [
{"price": 100.0, "quantity": 2},
{"price": 50.0, "quantity": 1},
]
discount = 0.1 # 10%
# When
total = calculate_total(items, discount=discount)
# Then
assert total == 225.0 # (100*2 + 50) * 0.9You can use comments, or just structure the code visually.
One Behavior per Test
A test should fail for one reason. Avoid long tests that check many unrelated behaviors.
Instead of:
def test_user_flow():
# register user
# login user
# update profile
# delete userPrefer:
test_register_user_succeeds_with_valid_datatest_login_succeeds_with_correct_credentialstest_update_profile_rejects_invalid_emailtest_delete_user_removes_data_from_database
This makes failures easier to debug.
Using Assertions
Automated tests must end with assertions that check expected outcomes.
Basic examples:
assert response.status_code == 200
assert user.is_active is True
assert len(tasks) == 3
assert "Authorization" in response.headersYou can also test that errors are raised, that lists are in a specific order, or that data structures match expected values.
For backend APIs, a typical pattern is:
def test_create_task_returns_201_and_task_data(client):
payload = {"title": "Write tests", "completed": False}
response = client.post("/tasks", json=payload)
assert response.status_code == 201
body = response.json()
assert body["title"] == "Write tests"
assert body["completed"] is False
assert "id" in bodyAutomated Testing With pytest
pytest is the main testing tool used in modern Python backends. It discovers and runs tests automatically.
A minimal pytest test file:
# tests/test_math_utils.py
from myapp.math_utils import add
def test_add_two_positive_numbers():
result = add(2, 3)
assert result == 5
def test_add_negative_number():
result = add(-1, 5)
assert result == 4Run it:
pytestPytest finds any file that:
- Is named
test_.pyor_test.py, and - Contains functions named
test_*.
You do not need to create classes or call unittest.main().
Using Fixtures for Setup and Teardown
In backend applications, many tests need the same setup:
- A test database
- A FastAPI test client
- A temporary configuration
If you repeat setup code inside each test, the tests become messy. Fixtures let you define reusable setup code.
Example: a simple fixture that prepares some data:
# tests/conftest.py
import pytest
from myapp.users import create_user
@pytest.fixture
def user_data():
return {"email": "user@example.com", "password": "secret123"}
@pytest.fixture
def user(user_data):
return create_user(**user_data)Using these fixtures in tests:
# tests/test_users.py
def test_created_user_has_hashed_password(user):
assert user.password != "secret123"
assert user.password.startswith("$2b$") # bcrypt prefixHere:
user_datais created before the test, returned to pytest.userusesuser_data, creates a user, and returns it to the test.- The test uses
userdirectly, no manual setup needed.
Fixtures can also clean up after tests by using yield, but detailed patterns are covered elsewhere in the testing section.
Example: FastAPI Test Client Fixture
For API testing you usually define a client fixture:
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import app
@pytest.fixture
def client():
return TestClient(app)Then your API tests become very concise:
# tests/api/test_auth_api.py
def test_register_user_returns_201(client):
payload = {"email": "new@example.com", "password": "secret123"}
response = client.post("/register", json=payload)
assert response.status_code == 201
body = response.json()
assert body["email"] == "new@example.com"
assert "id" in bodyAutomating Tests in Development
Automated tests are most useful when you run them often, not just before releases.
Common workflows:
| When | What to run |
|---|---|
| After writing a function | pytest tests/test_file.py |
| After finishing a small feature | pytest or test subset |
| Before committing | Fast test suite or subset |
| Before merging to main branch | Full test suite (often in CI) |
You can also use tools that watch files and rerun tests when code changes, for example pytest-xdist watch mode or external tools like entr.
A simple pattern is:
- Write a test that fails.
- Write the minimal code to make it pass.
- Run tests, confirm green.
- Refactor code if needed, test again.
This style is called Test-Driven Development (TDD), but even if you do not fully adopt TDD, writing tests close to the code change is very effective.
Automating Tests in CI/CD
Automated tests become powerful when they run on every push or merge request on a CI server.
The idea is simple:
- Developer pushes code to GitHub or GitLab.
- CI worker checks out the code.
- CI installs dependencies.
- CI runs
pytest. - If tests fail, the pipeline fails and the code is not deployed or merged.
This gives you an early warning system for breaking changes.
A minimal GitHub Actions example for a Python backend:
# .github/workflows/tests.yml
name: Run tests
on:
push:
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: pytestFor real projects you often:
- Use a test database in CI (for example PostgreSQL service).
- Set environment variables for test config.
- Generate test coverage reports and upload them.
But the core idea is always: code changes trigger tests automatically.
Never skip tests in CI for production branches. If tests fail, the build should fail. Deploying code with failing tests is a high risk.
Managing Test Data
Automated backend tests need data:
- Users, tasks, products, orders
- Database records
- Configuration values
Good practices:
- Use factories or helpers to create objects, instead of hardcoding large payloads.
- Reset or recreate the database for tests so that each test sees a clean state.
- For API tests, use specific test users like
"admin@example.com"or"user1@example.com"so it is clear who is who.
Example: a simple factory helper for tasks:
# tests/factories.py
from myapp.services.tasks import create_task
def create_test_task(title="Test task", completed=False, user_id=None):
return create_task(title=title, completed=completed, user_id=user_id)Then in a test:
from tests.factories import create_test_task
def test_list_tasks_returns_all_tasks(client):
create_test_task(title="Task 1")
create_test_task(title="Task 2")
response = client.get("/tasks")
assert response.status_code == 200
tasks = response.json()
titles = [t["title"] for t in tasks]
assert "Task 1" in titles
assert "Task 2" in titlesDealing With Flaky Tests
A test is flaky if it sometimes passes and sometimes fails without code changes.
Causes:
- Tests depend on real time.
- Tests rely on external services (real email, real payment gateway).
- Shared state between tests.
- Random values that are not fixed with a seed.
Flaky tests are dangerous, because developers start to ignore failures.
To avoid flakiness:
- Use fake or test doubles for external services (database, Redis, payment API).
- Use fixed times and random seeds when needed.
- Keep tests independent: each test should prepare its own data and not reuse global state.
When Not to Over-Test
Automated testing is important, but you can also overdo it:
- Testing internal implementation details that change often.
- Writing complex test setups that are harder to maintain than the code.
- Duplicating the same checks in many tests.
Focus on tests that:
- Verify public behavior of your functions, services, and endpoints.
- Protect core business logic: money handling, permissions, authentication, data integrity.
- Cover bug fixes. When you fix a bug, write a test that reproduces it and keep that test forever as a regression test.
You do not need a test for every single line of code, but you should have strong coverage for critical paths.
Summary
Automated testing turns your backend from "works on my machine" into something you can change and deploy with confidence:
- You write code that tests code.
- Tools like
pytestrun tests automatically. - Fixtures help you reuse setup for things like FastAPI clients and test databases.
- You run tests locally while developing, and in CI on every push.
- Tests should be clear, focused on behavior, and reliable.
With solid automated tests, later topics like CI/CD, deployment, and refactoring become much safer and much easier to handle.
Views: 9
KAHIBARO