TestingMethodologies (TDD/BDD)

Testing Methodologies

Testing methodologies provide structured approaches to writing tests and developing software. This guide covers Test-Driven Development (TDD) and Behavior-Driven Development (BDD).

Test-Driven Development (TDD)

Test-Driven Development is a software development approach where tests are written before the actual code.

The Red-Green-Refactor Cycle

TDD follows a simple three-step cycle:

🔴 Red → 🟢 Green → 🔵 Refactor → 🔴 Red → ...

1. 🔴 Red: Write a Failing Test

Write a test for the next bit of functionality you want to add. The test should fail because the functionality doesn’t exist yet.

// sum.test.ts
import { describe, it, expect } from 'vitest'
import { sum } from './sum'
 
describe('sum', () => {
  it('should add two numbers', () => {
    expect(sum(2, 3)).toBe(5) // This will fail - sum() doesn't exist yet
  })
})

2. 🟢 Green: Write Minimal Code to Pass

Write just enough code to make the test pass. Don’t worry about perfect code yet.

// sum.ts
export function sum(a: number, b: number): number {
  return a + b // Simplest implementation to pass the test
}

3. 🔵 Refactor: Improve the Code

Now that the test passes, improve the code quality while keeping the test green.

// sum.ts (refactored)
export function sum(...numbers: number[]): number {
  return numbers.reduce((acc, num) => acc + num, 0)
}
 
// Update test to cover new functionality
it('should add multiple numbers', () => {
  expect(sum(1, 2, 3, 4)).toBe(10)
})

TDD in Practice: Building a Shopping Cart

Let’s build a shopping cart feature using TDD:

Step 1: First Test (Red)

// cart.test.ts
import { describe, it, expect } from 'vitest'
import { Cart } from './cart'
 
describe('Cart', () => {
  it('should start with zero items', () => {
    const cart = new Cart()
    expect(cart.itemCount()).toBe(0)
  })
})

Step 2: Minimal Implementation (Green)

// cart.ts
export class Cart {
  private items: any[] = []
 
  itemCount(): number {
    return this.items.length
  }
}

Step 3: Add More Tests (Red)

it('should add item to cart', () => {
  const cart = new Cart()
  cart.addItem({ id: 1, name: 'Book', price: 10 })
 
  expect(cart.itemCount()).toBe(1)
})
 
it('should calculate total price', () => {
  const cart = new Cart()
  cart.addItem({ id: 1, name: 'Book', price: 10 })
  cart.addItem({ id: 2, name: 'Pen', price: 5 })
 
  expect(cart.getTotalPrice()).toBe(15)
})

Step 4: Implement Features (Green)

// cart.ts
interface CartItem {
  id: number
  name: string
  price: number
}
 
export class Cart {
  private items: CartItem[] = []
 
  addItem(item: CartItem): void {
    this.items.push(item)
  }
 
  itemCount(): number {
    return this.items.length
  }
 
  getTotalPrice(): number {
    return this.items.reduce((total, item) => total + item.price, 0)
  }
}

Step 5: Refactor (Blue)

// cart.ts (refactored with better types and methods)
interface CartItem {
  id: number
  name: string
  price: number
  quantity?: number
}
 
export class Cart {
  private items: Map<number, CartItem> = new Map()
 
  addItem(item: CartItem): void {
    const existingItem = this.items.get(item.id)
 
    if (existingItem) {
      existingItem.quantity = (existingItem.quantity || 1) + 1
    } else {
      this.items.set(item.id, { ...item, quantity: 1 })
    }
  }
 
  removeItem(itemId: number): void {
    this.items.delete(itemId)
  }
 
  itemCount(): number {
    return Array.from(this.items.values()).reduce(
      (count, item) => count + (item.quantity || 1),
      0
    )
  }
 
  getTotalPrice(): number {
    return Array.from(this.items.values()).reduce(
      (total, item) => total + item.price * (item.quantity || 1),
      0
    )
  }
}

Benefits of TDD

Better Design: Writing tests first leads to more testable, modular code

Living Documentation: Tests serve as up-to-date documentation

Fewer Bugs: Issues are caught early in development

Confidence: Refactor without fear of breaking things

Focus: Clear goal for each coding session

Drawbacks of TDD

Learning Curve: Takes time to master the workflow

Initial Slowdown: Feels slower at first (but pays off long-term)

Not Always Practical: UI or exploratory work may not fit TDD well

Maintenance: Tests need updating as requirements change

TDD Best Practices

  1. Write the smallest test possible
  2. Make it fail first to ensure the test actually tests something
  3. Write minimal code to pass the test
  4. Refactor both production and test code
  5. Commit after each green phase
  6. Test one thing at a time
  7. Keep tests fast to run them frequently

TDD is a discipline that takes practice. Start with simple functions and gradually apply it to more complex features.

Behavior-Driven Development (BDD)

BDD extends TDD by focusing on the behavior of the application from the user’s perspective. It uses natural language to describe tests.

Key Concepts

  • Collaboration: Developers, QA, and stakeholders work together
  • Ubiquitous Language: Shared vocabulary everyone understands
  • User Focus: Tests describe user behavior, not implementation
  • Living Documentation: Feature files serve as requirements

Given-When-Then Syntax

BDD tests follow a structured format:

  • Given: Initial context (the setup)
  • When: Action or event (what happens)
  • Then: Expected outcome (the verification)

Example Structure

Given [some context or initial state]
When [an event or action occurs]
Then [ensure some outcome]

Gherkin Language

Gherkin is a domain-specific language for writing BDD tests in plain English.

Basic Gherkin Syntax

Feature: User Login
  As a registered user
  I want to log into my account
  So that I can access my dashboard
 
  Scenario: Successful login with valid credentials
    Given I am on the login page
    When I enter valid credentials
      | email            | password  |
      | user@example.com | Pass123!  |
    And I click the "Login" button
    Then I should be redirected to the dashboard
    And I should see a welcome message
 
  Scenario: Failed login with invalid password
    Given I am on the login page
    When I enter invalid credentials
      | email            | password |
      | user@example.com | wrong    |
    And I click the "Login" button
    Then I should see an error message "Invalid credentials"
    And I should remain on the login page

Gherkin Keywords

KeywordPurpose
FeatureHigh-level description of functionality
ScenarioConcrete example of behavior
GivenInitial context/precondition
WhenEvent or action
ThenExpected outcome
AndAdditional step (continues previous type)
ButNegative additional step
BackgroundSteps that run before every scenario
ExamplesData table for scenario outlines

Cucumber Framework

Cucumber is a popular BDD framework that executes Gherkin feature files.

Installing Cucumber (JavaScript)

npm install --save-dev @cucumber/cucumber

Project Structure

features/
  ├── login.feature
  └── step_definitions/
      └── login.steps.ts
support/
  └── world.ts
cucumber.js

Writing Feature Files

# features/shopping-cart.feature
Feature: Shopping Cart
  As a customer
  I want to manage items in my cart
  So that I can purchase products
 
  Background:
    Given I am logged in as a customer
 
  Scenario: Add item to cart
    Given I am viewing product "Wireless Mouse"
    When I click "Add to Cart"
    Then the cart should contain 1 item
    And the cart total should be "$29.99"
 
  Scenario: Remove item from cart
    Given I have added "Wireless Mouse" to my cart
    When I remove "Wireless Mouse" from my cart
    Then the cart should be empty
    And the cart total should be "$0.00"
 
  Scenario Outline: Add multiple quantities
    Given I am viewing product "<product>"
    When I add <quantity> items to cart
    Then the cart should contain <quantity> items
 
    Examples:
      | product       | quantity |
      | USB Cable     | 2        |
      | HDMI Adapter  | 3        |
      | Laptop Stand  | 1        |

Writing Step Definitions

// features/step_definitions/shopping-cart.steps.ts
import { Given, When, Then } from '@cucumber/cucumber'
import { expect } from '@jest/globals'
 
Given('I am logged in as a customer', async function () {
  await this.loginAsCustomer('customer@example.com', 'password123')
})
 
Given('I am viewing product {string}', async function (productName: string) {
  await this.navigateToProduct(productName)
})
 
When('I click {string}', async function (buttonText: string) {
  await this.clickButton(buttonText)
})
 
When('I add {int} items to cart', async function (quantity: number) {
  await this.setQuantity(quantity)
  await this.clickButton('Add to Cart')
})
 
When('I remove {string} from my cart', async function (productName: string) {
  await this.removeFromCart(productName)
})
 
Then('the cart should contain {int} item(s)', async function (count: number) {
  const itemCount = await this.getCartItemCount()
  expect(itemCount).toBe(count)
})
 
Then('the cart should be empty', async function () {
  const isEmpty = await this.isCartEmpty()
  expect(isEmpty).toBe(true)
})
 
Then('the cart total should be {string}', async function (total: string) {
  const cartTotal = await this.getCartTotal()
  expect(cartTotal).toBe(total)
})

World Object (Shared Context)

// support/world.ts
import { setWorldConstructor, World } from '@cucumber/cucumber'
import { Browser, Page, chromium } from 'playwright'
 
export class CustomWorld extends World {
  browser!: Browser
  page!: Page
 
  async init() {
    this.browser = await chromium.launch()
    this.page = await this.browser.newPage()
  }
 
  async cleanup() {
    await this.page.close()
    await this.browser.close()
  }
 
  async navigateToProduct(productName: string) {
    await this.page.goto(`/products/${productName}`)
  }
 
  async clickButton(buttonText: string) {
    await this.page.click(`button:has-text("${buttonText}")`)
  }
 
  async getCartItemCount(): Promise<number> {
    const text = await this.page.textContent('.cart-count')
    return parseInt(text || '0', 10)
  }
}
 
setWorldConstructor(CustomWorld)

BDD with Jest

You can practice BDD principles without Cucumber by structuring Jest tests with Given-When-Then.

Example: BDD-Style Jest Tests

// login.spec.ts
import { describe, it, expect, beforeEach } from '@jest/globals'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginPage from './LoginPage'
 
describe('Feature: User Login', () => {
  describe('Scenario: Successful login with valid credentials', () => {
    let user: ReturnType<typeof userEvent.setup>
 
    beforeEach(() => {
      user = userEvent.setup()
    })
 
    it('should redirect to dashboard when user enters valid credentials', async () => {
      // Given I am on the login page
      render(<LoginPage />)
 
      // When I enter valid credentials
      const emailInput = screen.getByLabelText(/email/i)
      const passwordInput = screen.getByLabelText(/password/i)
 
      await user.type(emailInput, 'user@example.com')
      await user.type(passwordInput, 'Pass123!')
 
      // And I click the Login button
      const loginButton = screen.getByRole('button', { name: /login/i })
      await user.click(loginButton)
 
      // Then I should be redirected to the dashboard
      expect(window.location.pathname).toBe('/dashboard')
 
      // And I should see a welcome message
      expect(await screen.findByText(/welcome back/i)).toBeInTheDocument()
    })
  })
 
  describe('Scenario: Failed login with invalid password', () => {
    it('should show error message when password is incorrect', async () => {
      const user = userEvent.setup()
 
      // Given I am on the login page
      render(<LoginPage />)
 
      // When I enter invalid credentials
      await user.type(screen.getByLabelText(/email/i), 'user@example.com')
      await user.type(screen.getByLabelText(/password/i), 'wrongpassword')
 
      // And I click the Login button
      await user.click(screen.getByRole('button', { name: /login/i }))
 
      // Then I should see an error message
      expect(
        await screen.findByText(/invalid credentials/i)
      ).toBeInTheDocument()
 
      // And I should remain on the login page
      expect(window.location.pathname).toBe('/login')
    })
  })
})

BDD Test Structure with Comments

describe('Feature: Shopping Cart Management', () => {
  describe('Scenario: Adding items to cart', () => {
    it('should increase cart count when product is added', async () => {
      // GIVEN a customer is viewing a product
      const product = { id: 1, name: 'Laptop', price: 999 }
      render(<ProductPage product={product} />)
 
      // WHEN the customer clicks "Add to Cart"
      const addButton = screen.getByRole('button', { name: /add to cart/i })
      await userEvent.click(addButton)
 
      // THEN the cart count should increase
      const cartBadge = screen.getByTestId('cart-count')
      expect(cartBadge).toHaveTextContent('1')
 
      // AND a success notification should appear
      expect(screen.getByText(/added to cart/i)).toBeInTheDocument()
    })
  })
})

Collaboration Workflow

BDD shines when teams collaborate on feature specifications.

Three Amigos Meeting

The “Three Amigos” are:

  1. Developer: Builds the feature
  2. Tester (QA): Validates the feature
  3. Product Owner/BA: Defines the feature

Collaboration Process

  1. Discovery: Discuss the feature and write examples
  2. Formulation: Convert examples into Gherkin scenarios
  3. Automation: Implement step definitions
  4. Validation: Run tests and verify behavior

Example Collaboration Session

Feature: Password Reset
  As a user who forgot their password
  I want to reset it via email
  So that I can regain access to my account
 
  # Developer asks: What if email doesn't exist?
  Scenario: Request reset for non-existent email
    Given I am on the password reset page
    When I enter email "nonexistent@example.com"
    And I click "Send Reset Link"
    Then I should see "If that email exists, a reset link was sent"
    # Security: Don't reveal whether email exists
 
  # QA asks: What about rate limiting?
  Scenario: Multiple reset requests in short time
    Given I requested a password reset 2 minutes ago
    When I request another password reset
    Then I should see "Please wait before requesting another reset"
 
  # Product Owner adds: Link should expire
  Scenario: Using expired reset link
    Given I received a password reset link 25 hours ago
    When I click the reset link
    Then I should see "This reset link has expired"
    And I should be able to request a new link

BDD vs TDD Comparison

AspectTDDBDD
FocusHow code worksHow system behaves
LanguageTechnical (code)Natural (business)
AudienceDevelopersDevelopers, QA, stakeholders
TestsUnit/IntegrationAcceptance/Integration
ToolsJest, Vitest, MochaCucumber, SpecFlow, Behave
StartingWrite failing testWrite feature scenario
GoalCorrect implementationCorrect behavior
GranularityFine (functions/methods)Coarse (features/user stories)

When to Use TDD

  • Internal libraries and utilities
  • Complex algorithms and business logic
  • API development
  • Low-level component behavior

When to Use BDD

  • User-facing features
  • Cross-functional requirements
  • Acceptance criteria validation
  • Stakeholder communication

TDD and BDD are not mutually exclusive. Many teams use both: BDD for high-level acceptance tests and TDD for implementation details.

Practical Example: Building a Feature with BDD

1. Write Feature File

# features/search.feature
Feature: Product Search
  As a customer
  I want to search for products
  So that I can find what I'm looking for
 
  Scenario: Search with results
    Given there are products in the catalog
    When I search for "laptop"
    Then I should see at least 1 search result
    And each result should contain "laptop" in name or description
 
  Scenario: Search with no results
    Given there are products in the catalog
    When I search for "nonexistentproduct123"
    Then I should see "No results found"
    And I should see search suggestions

2. Implement Step Definitions

import { Given, When, Then } from '@cucumber/cucumber'
import { expect } from '@jest/globals'
 
Given('there are products in the catalog', async function () {
  await this.seedProducts([
    { name: 'Gaming Laptop', description: 'High-performance laptop' },
    { name: 'Office Mouse', description: 'Ergonomic mouse' },
  ])
})
 
When('I search for {string}', async function (query: string) {
  await this.page.fill('[data-testid="search-input"]', query)
  await this.page.click('[data-testid="search-button"]')
})
 
Then(
  'I should see at least {int} search result(s)',
  async function (count: number) {
    const results = await this.page.locator('[data-testid="result-item"]')
    expect(await results.count()).toBeGreaterThanOrEqual(count)
  }
)

3. Implement Feature

// SearchPage.tsx
export default function SearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
 
  const handleSearch = async () => {
    const data = await fetch(`/api/search?q=${query}`)
    const products = await data.json()
    setResults(products)
  }
 
  return (
    <div>
      <input
        data-testid="search-input"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button data-testid="search-button" onClick={handleSearch}>
        Search
      </button>
 
      {results.length === 0 ? (
        <p>No results found</p>
      ) : (
        results.map((product) => (
          <div key={product.id} data-testid="result-item">
            {product.name}
          </div>
        ))
      )}
    </div>
  )
}

Best Practices

For TDD

  1. Write the test you wish you had
  2. See it fail for the right reason
  3. Make it pass quickly
  4. Refactor carefully
  5. Repeat

For BDD

  1. Focus on user value
  2. Use concrete examples
  3. Keep scenarios independent
  4. Avoid technical details in feature files
  5. Maintain living documentation
  6. Involve the whole team
⚠️

Don’t write BDD tests for everything. Reserve them for important user-facing features and use unit tests for implementation details.

Next Steps