iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎯

TDD: How to design test cases and implementation from an Issue

に公開

Introduction

When junior engineers try to improve their technical skills, they tend to repeat a cycle of "just implement it." If you keep going just to get things working, it becomes difficult to internalize design concepts and how to read specifications.

TDD is a gateway to overcoming this. It naturally develops the mindset of identifying problems in specifications, considering design, and organizing implementation.

However, when starting TDD, the first hurdle is often "I don't know what tests to write."
It is difficult to practice if the process of where to start thinking about tests is not shared.

In this article, I will organize the thought process from receiving an Issue to designing tests.


Subject

Suppose you receive an Issue like the following.


Issue #42: Add discount functionality to orders

I want to be able to apply discounts based on the order amount.

Acceptance Criteria

  • A 10% discount applies when the order amount is 10,000 yen or more
  • No discount applies when the order amount is less than 10,000 yen
  • The discounted amount will not fall below 0 yen
  • 0 yen or negative values cannot be specified for the order amount

Step 1: Decompose Acceptance Criteria into "Conditions and Results"

When reading the acceptance criteria, the first thing to do is not to think about implementation, but to create a list of test cases.

Each line of the acceptance criteria almost directly becomes a test case.

Acceptance Criteria Test Case
10% discount when 10,000 yen or more When the amount is 10,000 yen, the discount amount is 1,000 yen
No discount when less than 10,000 yen When the amount is 9,999 yen, the discount amount is 0 yen
Discounted amount does not fall below 0 (Consider if boundary value issues occur -> Judge that it won't be below 0 with this discount rate, can be skipped)
Cannot specify 0 or negative values When the amount is 0 yen, an exception or error is returned
Cannot specify 0 or negative values When the amount is -1 yen, an exception or error is returned

The point is to convert the acceptance criteria into the format: "When [Condition], [Result] happens." This directly links to the test method names later.

Step 2: Increase cases while keeping boundary values in mind

The acceptance criteria state "10,000 yen or more." When you see a condition like this, you should check the boundary values.

  • 9,999 yen -> No discount (one step before the boundary)
  • 10,000 yen -> Discount applied (right at the boundary)
  • 10,001 yen -> Discount applied (one step past the boundary)
Acceptance Criteria: When 10,000 yen "or more"
                         ^ Includes 10,000 yen

If the implementer mistakenly writes > 10000 (greater than, not or more), boundary value tests will detect it.

Step 3: Organize the test case list

Based on Steps 1 and 2, organize the test cases.

[Normal path]
✅ When the amount is 10,000 yen, the discount is 1,000 yen
✅ When the amount is 10,001 yen, the discount is 1,000.1 yen
✅ When the amount is  9,999 yen, the discount is 0 yen
✅ When the amount is      1 yen, the discount is 0 yen

[Boundary values]
✅ When the amount is 10,000 yen (exactly at boundary), the discount is applied
✅ When the amount is  9,999 yen (one step before boundary), the discount is not applied

[Abnormal path]
✅ When the amount is 0 yen, an error occurs
✅ When the amount is -1 yen, an error occurs

This list serves as your checklist before writing test code.
You now have a state where everything you need to verify is decided without writing a single line of code.

Step 4: Decide which layer to write the tests in

Once the test cases are decided, the next step is to decide "which class or which layer to write the tests in."

The discount calculation in this case has the following characteristics:

  • No access to the database is required
  • No external API calls are required
  • The result is determined solely by input and output

These are domain logic. They can be written as pure Unit Tests.

Discount calculation rules -> DiscountCalculator (Domain Layer) -> Unit Test

If this were a test for a use case such as "retrieve an order from the DB, calculate the discount, and save it," it would be an application layer test.

Step 5: Translate into test code

Once the test case list and class design are decided, all that remains is to write the code.

public class DiscountCalculatorTests
{
    // Normal path: Case where discount is applied
    [Fact]
    public void When_amount_is_10000_yen_the_discount_is_1000_yen()
    {
        var calculator = new DiscountCalculator();

        var discount = calculator.Calculate(10_000m);

        Assert.Equal(1_000m, discount);
    }

    // Boundary value: Exactly at the boundary where the discount is applied
    [Theory]
    [InlineData(10_000, 1_000)]   // Exactly at boundary
    [InlineData(10_001, 1_000.1)] // One step past boundary
    [InlineData( 9_999, 0)]       // One step before boundary
    [InlineData(     1, 0)]       // Minimum value
    public void Discount_calculation_works_correctly_at_boundary_values(decimal amount, decimal expectedDiscount)
    {
        var calculator = new DiscountCalculator();

        var discount = calculator.Calculate(amount);

        Assert.Equal(expectedDiscount, discount);
    }

    // Abnormal path: Invalid amount
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Exception_is_thrown_when_amount_is_0_or_less(decimal amount)
    {
        var calculator = new DiscountCalculator();

        Assert.Throws<ArgumentOutOfRangeException>(
            () => calculator.Calculate(amount)
        );
    }
}

The test method names use the language from the case list created from the acceptance criteria. Depending on the project, I recommend within the team to write them in a way that prioritizes clarity.


What to do when acceptance criteria are ambiguous

In practice, acceptance criteria in an Issue might be ambiguous, or you might need to translate requirements directly into an Issue.

"Apply additional discount for large-scale customers"

This alone does not clarify "what a large-scale customer is" or "what the additional discount rate is." You will get stuck the moment you try to write tests.

In this case, the act of trying to write tests functions as a means of discovering holes in the specifications.

Try to write tests

Realize that "you cannot write tests without defining large-scale customers"

Confirm with the Issue creator (in some cases, consider acceptance conditions or specifications yourself)

Determined to be "purchase amount of 1 million yen or more in the past 12 months"

Tests can be written

"Cannot write tests = specifications are not decided" is a signal. By noticing this before entering implementation, you can prevent rework, which ultimately leads to a reduction in man-hours.


Summary: Flow from Issue to test design

Step Task
Step 1 Convert acceptance criteria into a 'When X, result is Y' format
Step 2 Add boundary and abnormal value cases
Step 3 Organize the test case list (complete before writing code)
Step 4 Decide which layer to write the tests in
Step 5 Translate into test code

When you feel that writing tests is difficult, it is often because either the specifications are not decided or responsibilities are mixed. TDD helps you discover problems in specification design early.

GitHubで編集を提案

Discussion