# 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 ```r 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: ```r 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: ```r 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 ```r # 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 ```r # tests/testthat/test-user-model.R describe("User model", { describe("validation", { ... }) describe("persistence", { ... }) describe("relationships", { ... }) }) ``` ### Multiple Related Components ```r # tests/testthat/test-math-operations.R describe("arithmetic operations", { describe("addition()", { ... }) describe("subtraction()", { ... }) describe("multiplication()", { ... }) describe("division()", { ... }) }) ``` ### Hierarchical Domain Organization ```r # 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: ```r # 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: ```r 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: ```r 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 ```r 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:** ```r describe("order_total()", { it("sums item prices") it("applies tax") it("applies discount codes") it("handles free shipping threshold") }) ``` 2. **Implement one specification at a time:** ```r 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") }) ``` 3. **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:** ```r 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:** ```r 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.