KAHIBARO
Discord Login Register

19.12. Automated Testing

Why Automate Testing?

Manual testing is fine for tiny scripts, but it quickly becomes painful for real applications. Every time you:

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:

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:

TypeScopeSpeedTypical Tools (Python)
Unit testsSingle function or methodFastpytest, unittest
IntegrationMultiple components togetherMediumpytest, test DB, test Redis
API testsHTTP endpointsMediumpytest, FastAPI TestClient, etc
End-to-endWhole system, like a userSlowpytest, HTTP clients, Postman
RegressionAny test guarding fixed bugsVariesWhatever 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:

This is often called the test pyramid.

Organizing Tests in a Project

A typical Python backend project has a structure like:

text
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.py

Common patterns:

You run tests from the project root:

bash
pytest          # discovers tests/ automatically
pytest tests/api
pytest tests/test_users.py::test_create_user

Writing 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:

python
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:

Example for a service function:

python
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.9

You 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:

python
def test_user_flow():
    # register user
    # login user
    # update profile
    # delete user

Prefer:

This makes failures easier to debug.

Using Assertions

Automated tests must end with assertions that check expected outcomes.

Basic examples:

python
assert response.status_code == 200
assert user.is_active is True
assert len(tasks) == 3
assert "Authorization" in response.headers

You 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:

python
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 body

Automated 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:

python
# 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 == 4

Run it:

bash
pytest

Pytest finds any file that:

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:

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:

python
# 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:

python
# tests/test_users.py
def test_created_user_has_hashed_password(user):
    assert user.password != "secret123"
    assert user.password.startswith("$2b$")  # bcrypt prefix

Here:

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:

python
# 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:

python
# 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 body

Automating Tests in Development

Automated tests are most useful when you run them often, not just before releases.

Common workflows:

WhenWhat to run
After writing a functionpytest tests/test_file.py
After finishing a small featurepytest or test subset
Before committingFast test suite or subset
Before merging to main branchFull 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:

  1. Write a test that fails.
  2. Write the minimal code to make it pass.
  3. Run tests, confirm green.
  4. 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:

  1. Developer pushes code to GitHub or GitLab.
  2. CI worker checks out the code.
  3. CI installs dependencies.
  4. CI runs pytest.
  5. 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:

yaml
# .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: pytest

For real projects you often:

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:

Good practices:

Example: a simple factory helper for tasks:

python
# 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:

python
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 titles

Dealing With Flaky Tests

A test is flaky if it sometimes passes and sometimes fails without code changes.

Causes:

Flaky tests are dangerous, because developers start to ignore failures.

To avoid flakiness:

When Not to Over-Test

Automated testing is important, but you can also overdo it:

Focus on tests that:

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:

With solid automated tests, later topics like CI/CD, deployment, and refactoring become much safer and much easier to handle.

Views: 9

Comments

Please login to add a comment.

Don't have an account? Register now!