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
- What testing means in software engineering
- How tests connect to business drivers and modeling
- Unit tests and the protection of domain rules
- AAA structure and test readability
- Parameterized tests and boundary design
- White-box and black-box perspectives
- End-to-end testing and behavior verification
- Testcontainers, VCR, and realistic integrations
- Why mocks are not the default for black-box integration confidence
- Requirements Traceability Matrix (RTM)
- BDD, Given/When/Then, and Gherkin
- How to write a solid test case
- 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.
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:
- Arrange: prepare the state, inputs, and relevant dependencies.
- Act: execute the behavior under test.
- Assert: verify the result, state change, or observable side effect.
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 == expectedNotice 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 TrueVCR
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 == 200The 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.
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:
- Given defines the initial context.
- When defines the action or event.
- Then defines the expected outcome.
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 == 200This 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.
- Objective: what the test is trying to verify.
- Precondition: the required state before execution starts.
- Test procedure: the exact steps, commands, or requests to run.
- Expected result: what should happen if the system is correct.
- Obtained result: what actually happened during execution.
- Post-condition: the final state after the test ends.
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.
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
- A Markdown document describing the test case and reporting the execution result.
- The automated test implementation.
- An RTM entry linking the requirement to the test and its evidence.
- A commit history with at least 3 commits related to the work.
Suggested Work Sequence
- Select a requirement or business driver that truly matters in the project.
- Write a complete manual test case first.
- Automate that same behavior.
- Run the automated test and capture the obtained result.
- Record the evidence in Markdown.
- Create the RTM link.
- Submit the GitLab repository URL.