[NEXT]
- Software Engineer @ Engineers Gate
- Real-time trading systems
- Scalable data infrastructure
- Python/C++/Rust developer
[NEXT]
Rust focuses on memory safety.
While supporting advanced concurrency.
Does a great job at this.
[NEXT] But even if our code is safe...
...we still need to make sure it's doing the right thing.
[NEXT]
- Rust unit tests
- Mocking in Rust using
double
- Design considerations
[NEXT SECTION]
note
- classist vs mockist testing
- look up newer literature for this
- say that we're going to start w/ classist testing then move to mockist
- basic Rust unit test
- chosen unit test framework
- same unit tests as before but in new framework
Correctness in our programs means that our code does what we intend for it to do. Rust is a programming language that cares a lot about correctness, but correctness is a complex topic and isn’t easy to prove. Rust’s type system shoulders a huge part of this burden, but the type system cannot catch every kind of incorrectness. As such, Rust includes support for writing software tests within the language itself.
(source: https://doc.rust-lang.org/book/second-edition/ch11-00-testing.html)
[NEXT]
Create library: cargo new
cargo new some_lib
cd some_lib
[NEXT] Test fixture automatically generated:
> cat src/lib.rs
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
// test code in here
}
}
[NEXT]
Write unit tests for a module by defining a private tests
module in its source file.
// production code
pub fn add_two(num: i32) -> i32 {
num + 2
}
#[cfg(test)]
mod tests {
// test code in here
}
note
Annotate tests module with #[cfg(test)]
so it's only built with cargo test
.
This module will also run when cargo test
is invoked.
"This is the automatically generated test module. The attribute cfg stands for configuration, and tells Rust that the following item should only be included given a certain configuration option. In this case, the configuration option is test, provided by Rust for compiling and running tests. By using this attribute, Cargo only compiles our test code if we actively run the tests with cargo test. This includes any helper functions that might be within this module, in addition to the functions annotated with #[test]."
Source: https://doc.rust-lang.org/book/second-edition/ch11-03-test-organization.html
[NEXT]
Add isolated test functions to private tests
module.
// ...prod code...
#[cfg(test)]
mod tests {
use super::*; // import production symbols from parent module
#[test]
fn ensure_two_is_added_to_negative() {
assert_eq!(0, add_two(-2));
}
#[test]
fn ensure_two_is_added_to_zero() {
assert_eq!(2, add_two(0));
}
#[test]
fn ensure_two_is_added_to_positive() {
assert_eq!(3, add_two(1));
}
}
note Emphasise the fact that each function is a separate, isolated test.
[NEXT]
cargo test
user:some_lib donaldwhyte$ cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running target/debug/deps/some_lib-4ea7f66796617175
running 3 tests
test tests::ensure_two_is_added_to_negative ... ok
test tests::ensure_two_is_added_to_positive ... ok
test tests::ensure_two_is_added_to_zero ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured
[NEXT] Rust has native support for:
- documentation tests
- integration tests
note Focus of talk is mocking, so these are not covered here.
[NEXT SECTION]
note Here's a component hierarchy.
note Suppose we want a test for the red component at the top there.
The component has three dependencies, or collaborators, which we build and pass into component at construction.
These collaborators might be rely on external systems or require a large amoun of setup. This makes testing the component difficult, because we either have to ensure these external systems are available and in the right state, or write lots more test code to setup the collaborators.
Since we aim to write tests for most of our components (or should), this extra effort builds up and results in huge amounts of development time taken up by tests.
...so then teams end up just not writing tests.
note To avoid this, we replace the implementations of these collaborators with much simpler, fake implementations.
note No more environment dependencies, no more massive setup. It becomes much quicker and easier to write the tests.
It also makes them less brittle. That is, they're less likely to break when the real, concrete dependencies are changed (this is a good and bad thing).
[NEXT]
Anything non-deterministic that can't be reliably controlled within a unit test.
[NEXT] External data sources — files, databases
Network connections — services
External code dependencies — libraries
[NEXT]
Large internal dependencies for simpler tests.
note
- tests become smaller
- only test one thing
- failures easier to understand
- easy to understand
- easy to change
Downsides to testing internal code dependencies:
- component is tested with mock collaborators that behave like you think they do
- real collaborators may behave differently
- real collaborators behaviour may change
- unit test with mocks won't pick that up
- still need integration tests to ensure real components work together
Despite these downsides, some believe the cost is worth simpler tests, because they:
- encourage developers to write more tests, since it requires less work
- tests are smaller, generally test one thing
- failures easier to understand
- tests are more maintainable
- easy to understand
- easy to change
[NEXT]
Term originates from a notion of a "stunt double" in films.
[NEXT] A test double is an object or function substituted for production code during testing.
Should behave in the same way as the production code.
Easier to control for testing purposes.
note This is how we eliminate these unwanted dependencies from our tests.
Similar to using a stunt double in films, where viewers don't notice that stunts are performed by a different actor.
[NEXT] Many types of test double:
- Stub
- Spy
- Mock
- Fake
They're often all just referred to "mocks".
note Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test.
Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive.
Source: https://martinfowler.com/articles/mocksArentStubs.html
[NEXT]
Spies are used in this talk.
[NEXT]
Tests code by asserting its interaction with its collaborators.
[NEXT SECTION]
[NEXT]
double
generates mock implementations for:
trait
s- functions
[NEXT] Flexible configuration of a double's behaviour.
Simple and complex assertions on how mocks were used/called.
[NEXT]
Predicting profit of a stock portfolio over time.
[NEXT]
pub trait ProfitModel {
fn profit_at(&self, timestamp: u64) -> f64;
}
[NEXT]
pub fn predict_profit_over_time<M: ProfitModel>(
model: &M,
start: u64,
end: u64) -> Vec<f64>
{
(start..end + 1)
.map(|t| model.profit_at(t))
.collect()
}
[NEXT]
We want to test predict_profit_over_time()
.
[NEXT] Tests should be repeatable.
Not rely on an external environment.
One collaborator — ProfitModel
.
[NEXT]
Real ProfitModel
implementations use:
- external data sources (DBs, APIs, files)
- complex internal code dependencies (math models)
[NEXT]
Let's mock ProfitModel
.
[NEXT]
mock_trait!
Generate mock struct
that records interaction:
pub trait ProfitModel {
fn profit_at(&self, timestamp: u64) -> f64;
}
mock_trait!(
MockModel,
profit_at(u64) -> f64);
[NEXT]
mock_trait!
mock_trait!(
NameOfMockStruct,
method1_name(arg1_type, ...) -> return_type,
method2_name(arg1_type, ...) -> return_type
...
methodN_name(arg1_type, ...) -> return_type);
[NEXT]
mock_method!
Generate implementations of all methods in mock struct
.
mock_trait!(
MockModel,
profit_at(u64) -> f64);
impl ProfitModel for MockModel {
mock_method!(profit_at(&self, timestamp: u64) -> f64);
}
[NEXT]
mock_method!
impl TraitToMock for NameOfMockStruct {
mock_method!(method1_name(&self, arg1_type, ...) -> return_type);
mock_method!(method2_name(&self, arg1_type, ...) -> return_type);
...
mock_method!(methodN_name(&self, arg1_type, ...) -> return_type);
}
[NEXT]
Full code to generate a mock implementation of a trait
:
mock_trait!(
MockModel,
profit_at(u64) -> f64);
impl ProfitModel for MockModel {
mock_method!(profit_at(&self, timestamp: u64) -> f64);
}
note Emphasise this is the only boilerplate needed.
[NEXT]
#[test]
fn test_profit_model_is_used_for_each_timestamp() {
// GIVEN:
let mock = MockModel::default();
mock.profit_at.return_value(10);
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
assert_eq!(vec!(10, 10, 10), profit_over_time);
assert_eq!(3, model.profit_at.num_calls());
}
note Emphasise the fact that the generated implementation records interaction after the code-under-test has run. This makes it a spy.
[NEXT]
note Mocks can be configured to return a single value, a sequence of values (one value for each call) or invoke a function/closure. Additionally, it is possible to make a mock return special value /invoke special functions when specific arguments are passed in.
[NEXT]
#[test]
fn no_return_value_specified() {
// GIVEN:
let mock = MockModel::default();
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
// default value of return type is used if no value is specified
assert_eq!(vec!(0, 0, 0), profit_over_time);
}
[NEXT]
#[test]
fn single_return_value() {
// GIVEN:
let mock = MockModel::default();
mock.profit_at.return_value(10);
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
assert_eq!(vec!(10, 10, 10), profit_over_time);
}
[NEXT]
#[test]
fn multiple_return_values() {
// GIVEN:
let mock = MockModel::default();
mock.profit_at.return_values(1, 5, 10);
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
assert_eq!(vec!(1, 5, 10), profit_over_time);
}
[NEXT]
#[test]
fn return_value_for_specific_arguments() {
// GIVEN:
let mock = MockModel::default();
mock.profit_at.return_value_for((1), 5);
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
assert_eq!(vec!(0, 5, 0), profit_over_time);
}
[NEXT]
#[test]
fn using_closure_to_compute_return_value() {
// GIVEN:
let mock = MockModel::default();
mock.profit_at.use_closure(|t| t * 5 + 1);
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
assert_eq!(vec!(1, 6, 11), profit_over_time);
}
[NEXT]
Verify mocks are called:
- the right number of times
- with the right arguments
[NEXT]
#[test]
fn asserting_mock_was_called() {
// GIVEN:
let mock = MockModel::default();
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
// Called at least once.
assert!(mock.profit_at.called());
// Called with argument 1 at least once.
assert!(mock.profit_at.called_with((1)));
// Called at least once with argument 1 and 0.
assert!(mock.profit_at.has_calls((1), (0)));
}
[NEXT]
#[test]
fn asserting_mock_was_called_with_precise_constraints() {
// GIVEN:
let mock = MockModel::default();
// WHEN:
let profit_over_time = predict_profit_over_time(&mock, 0, 2);
// THEN:
// Called exactly three times, with 1, 0 and 2.
assert!(mock.profit_at.has_calls_exactly((1), (0), (2)));
// Called exactly three times, with 0, 1 and 2 (in that order).
assert!(mock.profit_at.has_calls_exactly_in_order(
(0), (1), (2)
));
}
[NEXT]
Useful for testing code that takes function objects for runtime polymorphism.
[NEXT]
mock_func!
fn test_input_function_called_twice() {
// GIVEN:
mock_func!(mock, // variable that stores mock object
mock_fn, // variable that stores closure
i32, // return value type
i32); // argument 1 type
mock.return_value(10);
// WHEN:
code_that_calls_func_twice(&mock_fn);
// THEN:
assert_eq!(2, mock.num_calls());
assert!(mock.called_with(42));
}
[NEXT SECTION]
note When a mock function has been used in a test, we typically want to make assertions about what the mock has been called with.
[NEXT]
WorldState |
Struct containing current world state |
Robot |
Processes state of the world and makes decisions on what do to next. |
Actuator |
Manipulates the world. Used by Robot to act on the decisions its made. |
[NEXT]
[NEXT]
[NEXT]
pub trait Actuator {
fn move_forward(&mut self, amount: i32);
// ...
}
[NEXT]
mock_trait!(
MockActuator,
move_forward(i32) -> ());
impl Actuator for MockActuator {
mock_method!(move_forward(&mut self, amount: i32));
}
[NEXT]
pub struct Robot<A> {
actuator: &mut A
}
impl<A: Actuator> Robot {
pub fn new(actuator: &mut A) -> Robot<A> {
Robot { actuator: actuator }
}
pub fn take_action(&mut self, state: WorldState) {
// Complex business logic that decides what actions
// the robot should take.
// This is what we want to test.
}
}
}
[NEXT]
#[test]
fn test_the_robot() {
// GIVEN:
let input_state = WorldState { ... };
let actuator = MockActuator::default();
// WHEN:
{
let robot = Robot::new(&actuator);
robot.take_action(input_state);
}
// THEN:
assert!(actuator.move_forward.called_with(100));
}
note For example, suppose we're testing some logic that determines the next action of a robot. We might want to assert what this logic told the robot to do.
[NEXT] Do we really care that the robot moved exactly 100 units?
note Sometimes you might not want to be this specific. This can make tests being too rigid. Over specification leads to brittle tests and obscures the intent of tests. Therefore, it is encouraged to specify only what's necessary — no more, no less.
[NEXT]
[NEXT]
[NEXT]
[NEXT]
[NEXT]
[NEXT] Behaviour verification can overfit the implementation.
Lack of tooling makes this more likely.
note Without proper tooling, developers are more likely to use unnecessarily tight assertions when verifying behaviour.
Writing loose assertions can be surprisingly cumbersome.
[NEXT]
[NEXT] Match argument values to patterns.
Not exact values.
Loosens test expectations, making them less brittle.
[NEXT]
called_with_pattern()
[NEXT]
#[test]
fn test_the_robot() {
// GIVEN:
let input_state = WorldState { ... };
let actuator = MockActuator::default();
// WHEN:
{
let robot = Robot::new(&actuator);
robot.take_action(input_state);
}
// THEN:
let is_greater_or_equal_to_100 = |arg: &i32| *arg >= 100;
assert!(actuator.move_forward.called_with_pattern(
is_greater_than_or_equal_to_100
));
}
[NEXT] Parametrised matcher functions:
/// Matcher that matches if `arg` is greater than or
/// equal to `base_val`.
pub fn ge<T: PartialEq + PartialOrd>(
arg: &T,
base_val: T) -> bool
{
*arg >= base_val
}
[NEXT]
Use p!
to generate matcher closures on-the-fly.
use double::matcher::ge;
let is_greater_or_equal_to_100 = p!(ge, 100);
[NEXT]
use double::matcher::*;
#[test]
fn test_the_robot() {
// GIVEN:
let input_state = WorldState { ... };
let actuator = MockActuator::default();
// WHEN:
{
let robot = Robot::new(&actuator);
robot.take_action(input_state);
}
// THEN:
assert!(actuator.move_forward.called_with_pattern(
p!(ge, 100)
));
}
[NEXT]
[NEXT]
any() |
argument can be any value of the correct type |
[NEXT]
eq(value) |
argument == value |
ne(value) |
argument != value |
lt(value) |
argument < value |
le(value) |
argument <= value |
gt(value) |
argument > value |
ge(value) |
argument >= value |
is_some(matcher) |
arg is Option::Some , whose contents matches matcher |
is_ok(matcher) |
arg is Result::Ok , whose contents matches matcher |
is_err(matcher) |
arg is Result::er , whose contents matches matcher |
[NEXT]
f32_eq(value) |
argument is a value approximately equal to the f32 value , treating two NaNs as unequal. |
f64_eq(value) |
argument is a value approximately equal to the f64 value , treating two NaNs as unequal. |
nan_sensitive_f32_eq(value) |
argument is a value approximately equal to the f32 value , treating two NaNs as equal. |
nan_sensitive_f64_eq(value) |
argument is a value approximately equal to the f64 value , treating two NaNs as equal. |
[NEXT]
has_substr(string) |
argument contains string as a sub-string. |
starts_with(prefix) |
argument starts with string prefix . |
ends_with(suffix) |
argument ends with string suffix . |
eq_nocase(string) |
argument is equal to string , ignoring case. |
ne_nocase(value) |
argument is not equal to string , ignoring case. |
[NEXT]
is_empty |
argument implements IntoIterator and contains no elements. |
has_length(size_matcher) |
argument implements IntoIterator whose element count matches size_matcher . |
contains(elem_matcher) |
argument implements IntoIterator and contains at least one element that matches elem_matcher . |
each(elem_matcher) |
argument implements IntoIterator and all of its elements match elem_matcher . |
unordered_elements_are(elements) |
argument implements IntoIterator that contains the same elements as the vector elements (ignoring order). |
when_sorted(elements) |
argument implements IntoIterator that, when its elements are sorted, matches the vector elements . |
[NEXT]
Assert that a single arg should match many patterns.
// Assert robot moved between 100 and 200 units.
assert!(robot.move_forward.called_with_pattern(
p!(all_of, vec!(
p!(ge, 100),
p!(le, 200)
))
));
[NEXT]
Assert all elements of a collection match a pattern:
let mock = MockNumberRecorder::default();
mock.record_numbers(vec!(42, 100, -49395, 502));
// Check all elements in passed in vector are non-zero.
assert!(mock.record_numbers.called_with_pattern(
p!(each, p!(ne, 0))
));
[NEXT]
Define new matchers if the built-in ones aren't enough.
fn custom_matcher<T>(arg: &T, params...) -> bool {
// matching code here
}
[NEXT SECTION]
[NEXT]
2 design goals in double
.
[NEXT]
note
The vision for double
is that must work with stable Rust.
The vast majority of other mocking libraries that use nightly compiler plugins. This gives them more flexibility at the cost of restricting the user to nightly Rust.
[NEXT]
Allows traits
from the standard library or external crates to be mocked.
note It must don't impose code changes to the user's production code either. This makes supporting some features difficult.
The following other mocking libraries have similar feature sets to double
, require nightly:
- mockers (has partial support for stable)
- mock_derive
- galvanic-mock
- mocktopus
And none of them support mocking traits from the standard library or external crates.
[NEXT]
Meeting these goals is difficult, because Rust:
- is a compiled/statically typed language
- runs a borrow checker
[NEXT] Most mocking libraries require nightly.
Most (all?) mocking libraries require prod code changes.
[NEXT]
double
achieves the two goals at a cost.
Duplicate mock definitions.
[NEXT]
mock_trait!(
MockModel,
profit_at(u64) -> f64);
impl ProfitModel for MockModel {
mock_method!(profit_at(&self, timestamp: u64) -> f64);
}
[NEXT]
[NEXT]
Possible in C++:
decay<int>::type // type is `int`
decay<const int&>::type // type is `int`
decay<int&&>::type // type is `int`
Impossible in Rust stable:
decay<u32>::type // type is `u32`
decay<&u32>::type // type is `u32`
decay<&mut u32>::type // type is `u32`
decay<&static u32>::type // type is `u32`
[NEXT]
Supporting methods that take references.
Mocks store copies of received args for use in call assertions.
mock_trait
needs to decay reference types to value types when generating
the mock struct
.
[NEXT]
pub trait ResultWriter {
fn write(&self,
filename: &str,
results: &Vec<f64>,
timestamp: u32) -> ()
}
ResultWriter::write
takes two references.
[NEXT]
mock_trait!(
MockWriter,
write(String, // decayed form of `&str`
Vec<f64>, // decayed form of `&Vec<f64>`
u32));
impl ResultWriter for MockWriter {
mock_method!(write(&self,
filename: &str,
results: &Vec<f64>,
timestamp: u32));
}
Must specify ResultWriter::write
arguments twice.
note Once for the struct, which has the manually decayed arg types
And once for the trait impl
, which uses the trait method's original reference
types.
This is cumbersome and affects test code readability.
[NEXT]
mock_trait!(
ResultsWriter,
MockWriter,
write(&self,
filename: &str,
results: &Vec<f64>,
timestamp: u32)
);
Only need to specify arguments once!
References will be automatically decayed to value types.
[NEXT]
RFC 1210 introduces generic specialisation.
Makes type decay possible.
Only in nightly. No ETA on when it reaches stable.
note
double
is waiting on one nightly only feature to make it to be stable. Once that's one, the mock definitions will be even more concise.
[NEXT SECTION]
[NEXT] Mocking is used to isolate unit tests from exernal resources or complex dependencies.
Achieved in Rust by replacing trait
s and functions.
[NEXT] Behaviour verification can overfit implementation.
Pattern matching expands asserted behaviour space to reduce overfitting.
[NEXT]
double
is a crate for generating trait
/function mocks.
Wide array of behaviour setups and call assertions.
First-class pattern matching support.
Requires no changes to production code.
[NEXT]
note
For completeness, here's a list of other Rust mocking crates. In additional to checking out double
, I encourage you to look at these too. Depending on your use case and preference, one of these might be more suitable for you.
[NEXT]
- these slides:
- double repository:
- double documentation:
- example code from this talk:
[NEXT]
[[email protected]](mailto:[email protected])
[@donald_whyte](http://twitter.com/donald_whyte)
https://github.com/DonaldWhyte
[NEXT SECTION]
[NEXT]
pub trait ResultWriter {
fn write(&self,
filename: &str,
results: &Vec<f64>,
timestamp: u32) -> Result<(), String>;
}
[NEXT]
matcher!
#[test]
fn test_results_are_all_above_zero_and_written_to_csv_file() {
// GIVEN:
let writer = MockWriter::new(Ok());
// WHEN:
code_under_test(&writer);
// THEN:
assert(writer.called_with_pattern(
matcher!(
p!(ends_with, ".csv"), // filename
p!(each, p!(gt, 0)) // results
any // timestamp
)
));
}
[NEXT]