Introduction

This lesson treats testing as an engineering discipline, not as a final checklist performed after coding. The central idea is simple: the team must continuously produce evidence that the system still behaves according to the business drivers, requirements, architectural decisions, and operational constraints defined earlier in the project.

For that reason, the quality of a test strategy depends on how well it connects modeling, implementation, execution environments, and evidence. A good team does not merely "write tests". A good team decides what must be protected, why it matters, how it will be verified, and where the proof will be recorded.

Reading Guide

  1. What testing means in software engineering
  2. How tests connect to business drivers and modeling
  3. Unit tests and the protection of domain rules
  4. AAA structure and test readability
  5. Parameterized tests and boundary design
  6. White-box and black-box perspectives
  7. End-to-end testing and behavior verification
  8. Testcontainers, VCR, and realistic integrations
  9. Why mocks are not the default for black-box integration confidence
  10. Requirements Traceability Matrix (RTM)
  11. BDD, Given/When/Then, and Gherkin
  12. How to write a solid test case
  13. Graded activity

1) What Testing Means

In mature software engineering, testing is not the same as "running the application and seeing whether it works". Testing is the deliberate construction of evidence. Each test should answer a question that matters to the project. That question may be functional, such as whether a checkout request is accepted correctly, or non-functional, such as whether the system remains stable when an external dependency becomes slow.

The point of testing is therefore not limited to defect detection. Testing also supports communication, confidence, maintainability, change safety, and release decisions. When a team has clear tests, it becomes easier to refactor, easier to review pull requests, easier to integrate new members, and easier to explain quality status to stakeholders.

A useful mental model is this: requirements define what must be true, architecture defines where those truths are implemented, and tests define how the team proves those truths over time.

2) Testing and Business Drivers

Earlier in the project, the team models business drivers such as reliability, pricing accuracy, fraud prevention, authorization control, operational transparency, or time-sensitive service-level expectations. Those drivers should shape the testing strategy directly.

For example, if a project has a strong reliability driver, tests must verify not only the happy path but also timeout handling, retries, idempotency, failure visibility, and recovery behavior. If the project has a strong pricing driver, tests must emphasize boundary values, tax rules, discount accumulation, and rounding correctness. If security is a core driver, the tests must validate token handling, role checks, ownership rules, invalid input, and misuse scenarios.

The practical question is not "what tests can we write?" The practical question is "what risks from our modeled system must be continuously controlled?"

3) Unit Tests and Domain Rules

Unit tests are the most appropriate place to protect pure business rules and local decision logic. They are fast, deterministic, cheap to execute, and excellent at guarding invariants that should never change silently.

A team should prefer unit tests whenever the behavior can be verified without real infrastructure. If a pricing rule, permission decision, SLA calculation, or risk classification can be evaluated as pure logic, then unit tests should become the first defensive line. The purpose is not to prove that a method executes. The purpose is to prove that a business rule remains true after future changes.

def test_order_above_credit_limit_is_rejected():
    customer = Customer(limit=1000)
    order = Order(total=1300)

    result = approve_order(customer, order)

    assert result.status == "rejected"
    assert result.reason == "credit_limit"

This example is valuable because it protects a business rule. If a later refactor accidentally changes the approval logic, the test should fail immediately and locally.

4) AAA: Arrange, Act, Assert

One of the clearest ways to write a maintainable test is to use the AAA structure:

AAA matters because it controls cognitive load. When a test mixes setup, execution, and verification without a visible structure, readers cannot quickly understand what is important. When AAA is respected, the reader immediately knows what the test is preparing, what it is doing, and what it expects.

# Arrange
customer = Customer(limit=1000)
order = Order(total=1300)

# Act
result = approve_order(customer, order)

# Assert
assert result.status == "rejected"

A strong AAA test should usually perform one action and verify one main behavior. If a test starts acting multiple times or asserting many unrelated outcomes, it is often a sign that the test is doing too much.

5) Parameterized Tests

Many business rules are naturally expressed as sets of examples rather than a single case. Parameterized tests are ideal in those situations because they make the rule visible as a table. This is particularly useful for threshold logic, validation matrices, permission combinations, and boundary values.

Instead of repeating almost identical test bodies for many inputs, the team writes one test and varies the data. This reduces duplication while making the specification more explicit.

@pytest.mark.parametrize(
    "amount,expected",
    [(999, "approved"), (1000, "approved"), (1001, "rejected")]
)
def test_credit_limit_rule(amount, expected):
    customer = Customer(limit=1000)
    result = approve_order(customer, Order(total=amount))
    assert result.status == expected

Notice that the examples are meaningful: they represent values below the limit, exactly at the limit, and above the limit. That is a boundary-oriented design, which is much stronger than choosing random numbers.

6) White-Box and Black-Box Perspectives

It is useful to distinguish tests by perspective. A white-box test is designed with knowledge of the internal structure of the code. A black-box test is designed from behavior, contracts, and outcomes, regardless of the internal implementation.

Perspective Main Focus Typical Context Main Question
White-box Branches, internal paths, low-level decisions Unit tests, low-level component tests Did we exercise the internal logic safely?
Black-box Inputs, outputs, behavior, contracts, side effects APIs, integration tests, E2E tests, acceptance tests Does the system behave as promised?

Most business drivers are naturally expressed as black-box expectations because stakeholders do not care whether a condition was implemented with one class or five classes. They care whether the behavior is correct.

7) End-to-End Testing and Behavior

End-to-end tests should be treated as valuable but expensive assets. They are not designed to replicate every possible screen interaction. Their purpose is to protect a small set of critical workflows that must not fail in production.

An end-to-end test should verify more than a successful HTTP status or a green message on the interface. It should check the business outcome across the system boundary. That often includes persistence, emitted events, invalidated sessions, audit records, or other important side effects.

Journey: password reset

Checks:
- token validity
- password update persisted
- old sessions invalidated
- audit event emitted
- success response returned to the user

This is why E2E tests should remain small in number but high in importance. They should protect the flows whose failure would cause real harm to users or operations.

8) Realistic Integrations: Testcontainers and VCR

Integration tests become much more trustworthy when they interact with realistic collaborators instead of idealized substitutes. Two very practical tools for this are Testcontainers and VCR.

Testcontainers

Testcontainers allows the team to start disposable containers during the test run. This is especially useful when the system depends on services such as PostgreSQL, Redis, Kafka, RabbitMQ, or other infrastructure that should be tested in a realistic way. The gain here is substantial: configuration issues, schema mismatches, startup dependencies, environment assumptions, and network behavior become visible during automated tests.

from testcontainers.postgres import PostgresContainer

with PostgresContainer("postgres:16") as postgres:
    db_url = postgres.get_connection_url()
    repo = OrderRepository(db_url)
    assert repo.healthcheck() is True

VCR

VCR records real HTTP interactions and replays them later. It is extremely useful when a project depends on external APIs that are slow, rate-limited, paid, or unstable. The purpose is to keep the benefits of realistic payloads and responses without paying the cost of live calls every time the test suite runs.

import vcr
import requests

with vcr.use_cassette("fixtures/payment.yaml"):
    response = requests.get("https://api.example.com/payment/42")
    assert response.status_code == 200

The central idea is this: when the team needs integration confidence, it should prefer realistic interaction mechanisms over invented abstractions.

9) Why Mocks Are Not the Default for Black-Box Integration Confidence

Mocks are still valuable tools, especially for unit tests. They can isolate a unit of logic and remove unrelated side effects. However, they should not be the default choice when the purpose of the test is to validate integration, contracts, or realistic system behavior.

The core problem is that a mock often reproduces the team's assumptions about a dependency rather than the dependency's real behavior. Because of that, a mock may hide serialization mismatches, timeout behavior, schema drift, status-code differences, or environment configuration problems. The result is dangerous: the test suite becomes green while the system remains fragile at the real boundary.

Rule of thumb: use mocks mainly for unit isolation; use Testcontainers for internal infrastructure; use VCR or contract verification for external HTTP integrations.

10) RTM: Requirements Traceability Matrix

A Requirements Traceability Matrix, or RTM, is one of the clearest artifacts for connecting the earlier stages of the project to testing evidence. In practice, an RTM links business drivers, requirements, user stories, or acceptance criteria to the specific tests that verify them and to the evidence created during execution.

This matters for two reasons. First, it reveals coverage gaps. If an important requirement has no linked test, the team has a visibility problem. Second, it improves communication. Product, engineering, and quality stakeholders can all inspect the same artifact and see what has been verified, what remains open, and where the evidence lives.

Business Driver Requirement Test Case Status Evidence Commit
Reliability Reset token expires in 30 minutes TC-07 Passed report.md abc123
Security Only the account owner can update the profile TC-08 Passed test-output.md def456
Operational Traceability Password reset must create an audit event TC-09 Passed audit-check.md ghi789

A strong RTM answers three concrete questions: what must be true, how was it tested, and where is the proof?

11) BDD, Given/When/Then, and Gherkin

Behavior-Driven Development is an approach in which software behavior is described in business-oriented language before or during implementation. The purpose is to reduce ambiguity and create a shared understanding between stakeholders, product owners, and engineers.

The most common structure in BDD is Given / When / Then:

The textual notation used to write these scenarios is called Gherkin. Gherkin adds keywords such as Feature, Background, Rule, Scenario, and Scenario Outline, making the specification more expressive.

Feature: Password reset
  In order to recover account access
  As a registered user
  I want to reset my password safely

  Background:
    Given the password reset service is available
    And the audit log service is available

  Rule: Reset tokens expire after 30 minutes

  Scenario: Valid token allows password update
    Given a valid reset token created 10 minutes ago
    And the user is active
    When the user submits a strong new password
    Then the API returns 200
    And the password is updated
    And all previous sessions are invalidated
    And an audit event is recorded

  Scenario: Expired token is rejected
    Given a valid reset token created 31 minutes ago
    When the user submits a strong new password
    Then the API returns 401
    And the password is not updated
    And the response contains "token expired"

  Scenario Outline: Weak passwords are rejected
    Given a valid reset token created 5 minutes ago
    When the user submits "<password>"
    Then the API returns 422
    And the password is not updated
    And the response contains "<reason>"

    Examples:
      | password | reason              |
      | 123456   | password too weak   |
      | abcdef   | password too weak   |
      | short1   | password too short  |

How Gherkin Becomes Executable

In Python, one of the most common libraries for executing Gherkin scenarios is behave. It can be installed with pip install behave. The feature file remains readable for humans, while the library maps each step sentence to a Python function called a step definition.

pip install behave

from behave import given, when, then
import requests

@given("a valid reset token created 10 minutes ago")
def step_valid_token(context):
    context.token = create_reset_token(age_in_minutes=10)

@when("the user submits a strong new password")
def step_submit_password(context):
    context.response = requests.post(
        f"{context.base_url}/password-reset",
        json={"token": context.token, "password": "StrongPass123!"}
    )

@then("the API returns 200")
def step_status_200(context):
    assert context.response.status_code == 200

This mapping is what makes BDD operational. The scenario remains readable at the specification level, while the step definitions transform those sentences into executable test actions and assertions.

12) How to Write a Test Case

A test case is a structured description of how one relevant behavior should be verified. It is not just a title and an assertion. A proper test case must be detailed enough that another person can execute it, understand its purpose, and compare the observed result against the intended behavior.

Test case: Reject duplicate signup

Objective:
Prevent duplicate user registration

Precondition:
User email already exists in the database

Test procedure:
Send POST /users with the existing email

Expected result:
API returns 409 and no new user is created

Obtained result:
Observed during execution

Post-condition:
Only one user remains stored with that email

A strong test case must be reproducible, observable, and connected to an actual project risk or requirement.

13) Graded Activity

This in-class graded activity asks each team to move from test design to documented evidence and automation. The activity deliberately combines specification, execution, traceability, and repository discipline.

Describe a test case related to the project, detailing its objective, precondition, test procedure, expected result, obtained result, and post-condition. Automate this test using a tool of your choice, such as the Python requests library, for example. Submit the link to the GitLab repository containing the record of the results of this activity in Markdown format and the automated test code with at least 3 commits.

Expected Repository Contents

Suggested Work Sequence

  1. Select a requirement or business driver that truly matters in the project.
  2. Write a complete manual test case first.
  3. Automate that same behavior.
  4. Run the automated test and capture the obtained result.
  5. Record the evidence in Markdown.
  6. Create the RTM link.
  7. Submit the GitLab repository URL.