Skip to content

Latest commit

 

History

History
1236 lines (928 loc) · 32.1 KB

contents.md

File metadata and controls

1236 lines (928 loc) · 32.1 KB

Testing in Rust

A Primer in Testing and Mocking

@donald_whyte

fosdem

FOSDEM 2018

[NEXT]

About Me

![small_portrait](images/donald.jpg)
  • Software Engineer @ Engineers Gate
  • Real-time trading systems
  • Scalable data infrastructure
  • Python/C++/Rust developer

[NEXT]

Motivation

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]

Outline

  • Rust unit tests
  • Mocking in Rust using double
  • Design considerations

[NEXT SECTION]

1. Unit Tests

unit_testing

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]

2. What is Mocking?

why_mock

[NEXT] whymock

note Here's a component hierarchy.

[NEXT] whymock

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.

[NEXT] whymock

note To avoid this, we replace the implementations of these collaborators with much simpler, fake implementations.

[NEXT] whymock

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]

What to Eliminate

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]

Can Also Eliminate


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]

Solution: Use Test Double

stunt_double

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]

Spies Perform Behaviour Verification

Tests code by asserting its interaction with its collaborators.

[NEXT SECTION]

3. Test Doubles in Rust

Using double

double

[NEXT] double generates mock implementations for:

  • traits
  • functions

[NEXT] Flexible configuration of a double's behaviour.

Simple and complex assertions on how mocks were used/called.

[NEXT]

Example

prediction

Predicting profit of a stock portfolio over time.

[NEXT]

Collaborators

pub trait ProfitModel {
    fn profit_at(&self, timestamp: u64) -> f64;
}

[NEXT]

Implementation

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.

[NEXT] predicter_collaborators

One collaborator — ProfitModel.

[NEXT]

Predicting profit is hard

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]

Using Generated Mocks in Tests

#[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]

GIVEN: Setting Mock Behaviour

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]

Default Return Value

#[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]

One Return Value for All Calls

#[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]

Sequence of Return Values

#[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]

Return Values for Specific Args

#[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]

Use Closure to Compute Return Value

#[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]

THEN: Code Used Mock as Expected

Verify mocks are called:

  • the right number of times
  • with the right arguments

[NEXT]

Assert Calls Made

#[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]

Tighter Call Assertions

#[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]

Mocking Free Functions

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]

4. Pattern Matching

pattern_matching

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]

Robot Decision Making

actuator_large

[NEXT] robot_scenario

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]

Test the Robot's Decisions

robot_scenario

[NEXT]

Test the Robot's Decisions

robot_scenario

[NEXT]

Collaborators

pub trait Actuator {
    fn move_forward(&mut self, amount: i32);
    // ...
}

[NEXT]

Generate Mock Collaborators

mock_trait!(
    MockActuator,
    move_forward(i32) -> ());

impl Actuator for MockActuator {
    mock_method!(move_forward(&mut self, amount: i32));
}

[NEXT]

Implementation

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]

Testing the Robot

#[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]

![behaviour_space](images/behaviour_space1.svg)

[NEXT]

![behaviour_space](images/behaviour_space2.svg)

[NEXT]

![behaviour_space](images/behaviour_space3.svg)

[NEXT]

![behaviour_space](images/behaviour_space4.svg)

[NEXT]

![behaviour_space](images/behaviour_space5.svg)

[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]

Pattern Matching to the Rescue

[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]

Built-in Matchers

[NEXT]

Wildcard
any() argument can be any value of the correct type

[NEXT]

Comparison Matchers
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]

Floating-Point Matchers
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]

String Matchers
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]

Container Matchers
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]

Composite Matchers

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]

Composite Matchers

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]

Custom Matchers

Define new matchers if the built-in ones aren't enough.

fn custom_matcher<T>(arg: &T, params...) -> bool {
    // matching code here
}

[NEXT SECTION]

5. Design Considerations

limitations

[NEXT]

2 design goals in double.

[NEXT]

1. Rust Stable First

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]

2. No Changes to Production Code Required

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]

Challenging

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]

The Cost

double achieves the two goals at a cost.

Duplicate mock definitions.

[NEXT]

Duplicate mock definitions

mock_trait!(
    MockModel,
    profit_at(u64) -> f64);

impl ProfitModel for MockModel {
    mock_method!(profit_at(&self, timestamp: u64) -> f64);
}

[NEXT]

Why Duplicate Definitions?

[NEXT]

No Type Decay in Stable Rust

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]

Why is Type Decay Needed?

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]

Example

pub trait ResultWriter {
    fn write(&self,
             filename: &str,
             results: &Vec<f64>,
             timestamp: u32) -> ()
}

ResultWriter::write takes two references.

[NEXT]

The Present — Without Decay

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]

The Future — With Decay

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]

Generic Specialisation

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]

Fin

fin

[NEXT] Mocking is used to isolate unit tests from exernal resources or complex dependencies.

Achieved in Rust by replacing traits 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]

Alternative Mocking Libraries

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]

Links

[NEXT]

Get In Touch

![small_portrait](images/donald.jpg)

[NEXT SECTION]

Appendix

[NEXT]

Pattern Matching with Multiple Args

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]

Image Credits