Files
2025-11-30 08:48:12 +08:00

10 KiB

BDD-Style Testing with describe() and it()

Behavior-Driven Development (BDD) testing uses describe() and it() to create specification-style tests that read like natural language descriptions of behavior.

When to Use BDD Syntax

Use BDD (describe/it) when:

  • Documenting intended behavior of new features
  • Testing complex components with multiple related facets
  • Following test-first development workflows
  • Tests serve as specification documentation
  • You want hierarchical organization of related tests
  • A group of tests (in it() statements) rely on a single fixture or local options/envvars (set up in describe())

Use standard syntax (test_that) when:

  • Writing straightforward unit tests
  • Testing implementation details
  • Converting existing test_that() tests (no need to change working code)

Key insight from testthat: "Use describe() to verify you implement the right things, use test_that() to ensure you do things right."

Basic BDD Syntax

Simple Specifications

describe("matrix()", {
  it("can be multiplied by a scalar", {
    m1 <- matrix(1:4, 2, 2)
    m2 <- m1 * 2
    expect_equal(matrix(1:4 * 2, 2, 2), m2)
  })

  it("can be transposed", {
    m <- matrix(1:4, 2, 2)
    expect_equal(t(m), matrix(c(1, 3, 2, 4), 2, 2))
  })

  it("can compute determinant", {
    m <- matrix(c(1, 2, 3, 4), 2, 2)
    expect_equal(det(m), -2)
  })
})

Each it() block:

  • Defines one specification (like test_that())
  • Runs in its own environment
  • Has access to all expectations
  • Can use withr and other testing tools

Nested Specifications

Group related specifications hierarchically:

describe("User authentication", {
  describe("login()", {
    it("accepts valid credentials", {
      result <- login("user@example.com", "password123")
      expect_true(result$authenticated)
      expect_type(result$token, "character")
    })

    it("rejects invalid email", {
      expect_error(
        login("invalid-email", "password"),
        class = "validation_error"
      )
    })

    it("rejects wrong password", {
      expect_error(
        login("user@example.com", "wrong"),
        class = "auth_error"
      )
    })
  })

  describe("logout()", {
    it("clears session token", {
      session <- create_session()
      logout(session)
      expect_null(session$token)
    })

    it("invalidates refresh token", {
      session <- create_session()
      logout(session)
      expect_error(refresh(session), "Invalid token")
    })
  })

  describe("password_reset()", {
    it("sends reset email", {
      local_mocked_bindings(send_email = function(...) TRUE)
      result <- password_reset("user@example.com")
      expect_true(result$email_sent)
    })

    it("generates secure token", {
      result <- password_reset("user@example.com")
      expect_gte(nchar(result$token), 32)
    })
  })
})

Nesting creates clear hierarchies:

  • Top level: Component or module
  • Second level: Functions or features
  • Third level: Specific behaviors

Pending Specifications

Mark unimplemented tests by omitting the code:

describe("division()", {
  it("divides two numbers", {
    expect_equal(division(10, 2), 5)
  })

  it("returns Inf for division by zero")  # Pending

  it("handles complex numbers")  # Pending
})

Pending tests:

  • Show up in test output as "SKIPPED"
  • Document planned functionality
  • Serve as TODO markers
  • Don't cause test failures

Complete Test File Example

# tests/testthat/test-data-processor.R

describe("DataProcessor", {
  describe("initialization", {
    it("creates processor with default config", {
      proc <- DataProcessor$new()
      expect_r6_class(proc, "DataProcessor")
      expect_equal(proc$config$timeout, 30)
    })

    it("accepts custom configuration", {
      proc <- DataProcessor$new(config = list(timeout = 60))
      expect_equal(proc$config$timeout, 60)
    })

    it("validates configuration options", {
      expect_error(
        DataProcessor$new(config = list(timeout = -1)),
        "timeout must be positive"
      )
    })
  })

  describe("process()", {
    describe("with valid data", {
      it("processes numeric data", {
        proc <- DataProcessor$new()
        result <- proc$process(data.frame(x = 1:10))
        expect_s3_class(result, "data.frame")
        expect_equal(nrow(result), 10)
      })

      it("handles missing values", {
        proc <- DataProcessor$new()
        data <- data.frame(x = c(1, NA, 3))
        result <- proc$process(data)
        expect_false(anyNA(result$x))
      })

      it("preserves column names", {
        proc <- DataProcessor$new()
        data <- data.frame(foo = 1:3, bar = 4:6)
        result <- proc$process(data)
        expect_named(result, c("foo", "bar"))
      })
    })

    describe("with invalid data", {
      it("rejects NULL input", {
        proc <- DataProcessor$new()
        expect_error(proc$process(NULL), "data cannot be NULL")
      })

      it("rejects empty data frame", {
        proc <- DataProcessor$new()
        expect_error(proc$process(data.frame()), "data cannot be empty")
      })

      it("rejects non-data.frame input", {
        proc <- DataProcessor$new()
        expect_error(proc$process(list()), class = "type_error")
      })
    })
  })

  describe("summary()", {
    it("returns summary statistics", {
      proc <- DataProcessor$new()
      data <- data.frame(x = 1:10, y = 11:20)
      proc$process(data)

      summary <- proc$summary()
      expect_type(summary, "list")
      expect_named(summary, c("rows", "cols", "processed_at"))
    })

    it("throws error if no data processed", {
      proc <- DataProcessor$new()
      expect_error(proc$summary(), "No data has been processed")
    })
  })
})

Organizing Files with BDD

Single Component per File

# tests/testthat/test-user-model.R
describe("User model", {
  describe("validation", { ... })
  describe("persistence", { ... })
  describe("relationships", { ... })
})
# tests/testthat/test-math-operations.R
describe("arithmetic operations", {
  describe("addition()", { ... })
  describe("subtraction()", { ... })
  describe("multiplication()", { ... })
  describe("division()", { ... })
})

Hierarchical Domain Organization

# tests/testthat/test-api-endpoints.R
describe("API endpoints", {
  describe("/users", {
    describe("GET /users", { ... })
    describe("POST /users", { ... })
    describe("GET /users/:id", { ... })
  })

  describe("/posts", {
    describe("GET /posts", { ... })
    describe("POST /posts", { ... })
  })
})

Mixing BDD and Standard Syntax

You can use both styles in the same test file:

# tests/testthat/test-calculator.R

# BDD style for user-facing functionality
describe("Calculator user interface", {
  describe("button clicks", {
    it("registers numeric input", { ... })
    it("handles operator keys", { ... })
  })
})

# Standard style for internal helpers
test_that("parse_expression() tokenizes correctly", {
  tokens <- parse_expression("2 + 3")
  expect_equal(tokens, c("2", "+", "3"))
})

test_that("evaluate_tokens() handles operator precedence", {
  result <- evaluate_tokens(c("2", "+", "3", "*", "4"))
  expect_equal(result, 14)
})

Mixing guidelines:

  • Use BDD for behavioral specifications
  • Use test_that() for implementation details
  • Keep related tests in the same style within a section
  • Don't nest test_that() inside describe() or vice versa

BDD with Test Fixtures

Use the same fixture patterns as standard tests:

describe("File processor", {
  # Helper function for tests
  new_test_file <- function(content) {
    path <- withr::local_tempfile(lines = content)
    path
  }

  describe("read_file()", {
    it("reads text files", {
      file <- new_test_file(c("line1", "line2"))
      result <- read_file(file)
      expect_length(result, 2)
    })

    it("handles empty files", {
      file <- new_test_file(character())
      result <- read_file(file)
      expect_equal(result, character())
    })
  })
})

BDD with Snapshot Tests

Snapshots work naturally with BDD:

describe("error messages", {
  it("provides helpful validation errors", {
    expect_snapshot(error = TRUE, {
      validate_user(NULL)
      validate_user(list())
      validate_user(list(email = "invalid"))
    })
  })

  it("shows clear permission errors", {
    expect_snapshot(error = TRUE, {
      check_permission("guest", "admin")
    })
  })
})

BDD with Mocking

describe("API client", {
  describe("fetch_user()", {
    it("handles successful response", {
      local_mocked_bindings(
        http_get = function(url) {
          list(status = 200, body = '{"id": 1, "name": "Test"}')
        }
      )

      user <- fetch_user(1)
      expect_equal(user$name, "Test")
    })

    it("handles 404 errors", {
      local_mocked_bindings(
        http_get = function(url) list(status = 404)
      )

      expect_error(fetch_user(999), class = "not_found_error")
    })
  })
})

Test-First Workflow with BDD

  1. Write specifications first:
describe("order_total()", {
  it("sums item prices")
  it("applies tax")
  it("applies discount codes")
  it("handles free shipping threshold")
})
  1. Implement one specification at a time:
describe("order_total()", {
  it("sums item prices", {
    order <- list(items = list(
      list(price = 10),
      list(price = 20)
    ))
    expect_equal(order_total(order), 30)
  })

  it("applies tax")
  it("applies discount codes")
  it("handles free shipping threshold")
})
  1. Continue until all specs have implementations

This workflow ensures you:

  • Think about requirements before implementation
  • Have clear success criteria
  • Build incrementally
  • Maintain focus on behavior

Comparison: describe/it vs test_that

describe/it:

describe("str_length()", {
  it("counts characters in string", {
    expect_equal(str_length("abc"), 3)
  })

  it("handles empty strings", {
    expect_equal(str_length(""), 0)
  })
})

test_that:

test_that("str_length() counts characters", {
  expect_equal(str_length("abc"), 3)
})

test_that("str_length() handles empty strings", {
  expect_equal(str_length(""), 0)
})

Key differences:

  • BDD groups related specs under describe()
  • BDD uses "it" instead of "test_that"
  • BDD enables nesting for hierarchy
  • BDD supports pending specs without code
  • Both produce identical test results

Choose based on your preferences and project style.