Better tests with TypR
Test-Driven Development (TDD) is a well-recognized practice for improving code quality. Yet its adoption often remains limited due to the friction it imposes: juggling between code and test files, maintaining synchronization between logic and tests, and managing sometimes complex test architecture.
TypR offers an elegant solution to this problem: inline test blocks.
The Problem with Classical TDD in R
In a traditional R project using testthat, the typical structure looks like this:
my-package/
├── R/
│ └── person.R
└── tests/
└── testthat/
└── test-person.R
This separation between source code and tests creates several points of friction:
- Cognitive distance: you need to navigate between two files to understand the expected behavior of a function
- Manual synchronization: when modifying a function, you need to remember to open the corresponding test file
- Mental overhead: the structure requires maintaining two parallel directory trees
These frictions, while seemingly minor, accumulate and can discourage TDD adoption, especially for less experienced developers.
The TypR Solution: Inline Test Blocks
TypR introduces inline test blocks, allowing you to write tests directly in the same file as the logic they test. Here's a concrete example:
# person.ty
# Type definition
type Person <- list {
name: char,
age: int
};
# Constructor
let new_person <- fn(name: char, age: int): Person {
list(name = name, age = age)
};
# first method
let greet <- fn(self: Person): char {
paste0("Hello, my name is ", self$name,
" and I am ", self$age, " years old.")
};
# second method
let is_adult <- fn(self: Person): bool {
self$age >= 18
};
# Test block
Test {
# first test suite
test_that("Person initialization works correctly", {
let person <- new_person("Alice", 25)
expect_equal(person$name, "Alice")
expect_equal(person$age, 25)
})
# second test suite
test_that("greet returns correct message", {
let person <- new_person("Bob", 30)
expect_equal(
person.greet(),
"Hello, my name is Bob and I am 30 years old."
)
})
# third test suite
test_that("is_adult correctly identifies adults", {
let adult <- new_person("Charlie", 25)
let minor <- new_person("David", 15)
expect_true(adult.is_adult())
expect_false(minor.is_adult())
})
}
How Does It Work?
During transpilation, TypR takes an intelligent approach:
- Automatic separation: TypR code is transpiled to native R in the
R/folder - Test extraction:
Test { }blocks are extracted and transpiled totestthat - Conventional organization: tests are automatically placed in
tests/testthat/test-<filename>.R
For our person.ty example, TypR generates:
my-package/
├── R/
│ └── person.R # Transpiled native R code
└── tests/
└── testthat/
└── test-person.R # Tests extracted from #!test block
The final result is a standard, compatible R package that respects all R ecosystem conventions, while benefiting from the advantages of inline test blocks during development.
Cognitive Proximity and shorter development Cylcle
Logic and its tests are side by side. No more navigating between files to understand the expected behavior of a function. This proximity facilitates:
- Code understanding for new contributors
- Maintenance: you immediately see which tests are affected by a change
- Living documentation: tests serve as specification directly visible
Classical TDD involves a "Red-Green-Refactor" cycle:
- Write a failing test
- Write minimal code to make it pass
- Refactor
With TypR, this cycle becomes more fluid because everything happens in the same editing context. No file switching, no loss of focus.
Use Case: Test-Driven Development
Imagine we want to add a method to calculate birth year. With TypR, the TDD workflow becomes:
# ... existing code ...
let birth_year <- fn() -> int {
# TODO: implement
}
Test {
# ... existing tests ...
test_that("birth_year calculates correctly", {
let person <- new_person("Eve", 30)
let current_year <- as__integer(format(Sys.Date(), "%Y"))
let expected_year <- current_year - 30
expect_equal(person.birth_year(), expected_year)
})
}
- I write the test first (Red)
- I implement the function just above (Green)
- I refactor if needed, with tests there to protect me
All in one file, one context, one continuous workflow.
Conclusion
Of course there are some limitations. Inline test blocks are not a silver bullet. Here are some points to consider:
File size: For functions with many tests, the file can become long. In this case, it's still possible to create traditional separate test files as a complement.
Team habits: Teams accustomed to strict code/test separation may need some adjustment time.
CI/CD integration: No impact since TypR transpiles to standard R. Your existing pipelines work without modification.
TypR's inline test blocks don't revolutionize TDD, but they make it more accessible and more enjoyable. By reducing friction between code and tests, they naturally encourage TDD practice adoption.
TypR's philosophy is simple: add typing and ergonomics to R without sacrificing ecosystem compatibility. Inline test blocks fit perfectly into this vision.
If you develop R packages and have always found TDD too constraining, TypR might just change your perspective. And if you're already convinced by TDD, inline test blocks will make your workflow even smoother.