ForgeStream
Back to blog

Testing failure modes using error injection

Testing failure modes using error injection

Testing failure modes using error injection

When writing unit tests, it’s a good idea to test not only the happy path, but all of the failure modes as well. One way to achieve this is through error injection, also known as “failure points”.

A contrived example

Suppose we have a function that takes a string as input, splits it into chunks, and then uploads each chunk to an endpoint somewhere.

It might look something like this:

Note: Typically you would use an async function for a web request, but we’ll keep it simple for this example.

#[derive(Debug, thiserror::Error)]
pub enum UploadError {
    #[error("data is invalid")]
    InvalidData,
    #[error("{0}")]
    UploadFailed(String),
}

pub trait Uploader {
    fn upload(&self, data: String) -> Result<(), UploadError>;
}

fn upload_string(input: &str, uploader: &dyn Uploader) -> Result<(), String> {
    // Split the string into chunks of 3 characters each.
    let chunks = input.chars().chunks(3);

    // Upload each chunk.
    for chunk in &chunks {
        let chunk = chunk.collect::<String>();

        // Try uploading each chunk a maximum of 3 times.
        // If it still fails, return an error.
        let mut tries_remaining = 3;
        loop {
            match uploader.upload(chunk.clone()) {
                Ok(()) => break,
                Err(e @ UploadError::UploadFailed(_)) => {
                    warn!("Upload failed: {e}");
                    tries_remaining -= 1;
                    if tries_remaining == 0 {
                        return Err(e.to_string());
                    }
                }
                Err(e) => {
                    return Err(e.to_string());
                }
            }
        }
    }

    Ok(())
}

We created an Uploader trait so that we can mock the actual upload for testing purposes. That way we can test the logic of this function without requiring an actual web request.

We can easily test the happy path, using a mock uploader like this:

    #[test]
    fn test_upload() {
        let input = "This is a test";
        let uploader = MockUploader::default();
        upload_string(input, &uploader).unwrap();
        assert_eq!(uploader.value(), input);
    }

We’ll get to the mock code soon, but now let’s talk about the error paths.

How do we test the following scenarios?

  • One chunk fails, but then succeeds
  • Multiple chunks fail, but then succeed
  • A chunk fails 3 times

Error injection

We could just add a flag in the mock for each scenario, and then use if conditions everywhere to make it work. But this moves our test logic outside the tests and it’s not very reusable.

What if there was a way to describe the desired failure scenario in the test, and then verify the results of that scenario? Even better, what if it could be generic over the error type so that it could be easily reused?

First, let’s define an ErrorTrigger. It’s generic over the error type so that it can be easily reused. Its job is to track how many times it has been invoked and conditionally construct and return an error if required.

We also need an ErrorFrequency so we know when to trigger the error.

pub enum ErrorFrequency {
    Never,
    Always,
    Sequence(HashSet<usize>),
}

impl ErrorFrequency {
    pub fn should_trigger(&self, count: usize) -> bool {
        match self {
            Self::Never => false,
            Self::Always => true,
            Self::Sequence(x) => x.contains(&count),
        }
    }
}

pub struct ErrorTrigger<E> {
    /// The number of times the trigger has been called.
    count: usize,
    /// The number of times the trigger returned an error.
    trigger_count: usize,
    /// When to return an error.
    frequency: ErrorFrequency,
    /// Closure that creates the error.
    error_fn: Box<dyn Fn() -> E>,
}

impl<E> ErrorTrigger<E> {
    pub fn new<F>(frequency: ErrorFrequency, error_fn: F) -> Self
    where
        F: Fn() -> E + 'static,
    {
        Self {
            count: 0,
            trigger_count: 0,
            frequency,
            error_fn: Box::new(error_fn),
        }
    }

    pub fn tick(&mut self) -> Result<(), E> {
        self.count += 1;
        if self.frequency.should_trigger(self.count) {
            self.trigger_count += 1;
            return Err((self.error_fn)());
        }
        Ok(())
    }
}

Now, we can construct the trigger in our test by specifying exactly when it should fail, and call the tick() method from our mock uploader and it will “inject” the error according to the schedule we predefined in the test.

Before we create the test, let’s take a look at the code for the mock uploader.

Creating the mock

Remember the Uploader trait from our first code snippet? Let’s create a mock that implements that trait.

The mock collects the chunks that are uploaded and then stores them in a vec.

// Using mutexes so that we can modify the values behind &self.
#[derive(Default)]
pub struct MockUploader {
    received: Mutex<Vec<String>>,
    error_trigger: Mutex<Option<ErrorTrigger<UploadError>>>,
}

impl Uploader for MockUploader {
    fn upload(&self, data: String) -> Result<(), UploadError> {
        // Optionally return an error, if specified by the test.
        if let Some(trigger) = self.error_trigger.lock().expect("lock poisoned").as_mut() {
            trigger.tick()?;
        }

        // Save each chunk to verify later.
        self.received.lock().expect("lock poisoned").push(data.to_string());
        Ok(())
    }
}

That’s all we need to make the error trigger work. There is some boilerplate to lock the mutex but essentially we just call the tick() method and bubble up the error if there was one.

Let’s add some helper functions to the mock to make our test code nicer.

impl MockUploader {
    /// Add an `ErrorTrigger` to the mock.
    pub fn with_error_trigger(mut self, trigger: ErrorTrigger<UploadError>) -> Self {
        self.error_trigger = Mutex::new(Some(trigger));
        self
    }

    /// Join the received chunks back into a string.
    pub fn value(&self) -> String {
        self.received.lock().expect("lock poisoned").join("")
    }

    /// Get the number of times the mock returned an error.
    pub fn error_trigger_count(&self) -> usize {
        self.error_trigger
            .lock()
            .expect("lock poisoned")
            .as_ref()
            .map(|x| x.trigger_count)
            .unwrap_or_default()
    }
}

Writing the tests

Now we’re ready to write the tests.

We said we wanted to test these scenarios:

  • One chunk fails, but then succeeds
  • Multiple chunks fail, but then succeed
  • First chunk fails 3 times

Writing tests for these scenarios is now quite straightforward. All we need to do is construct the required error trigger and then pass the mock to our upload_string() function.

#[test]
fn test_upload_transient_fail() {
    let input = "This is a test";
    let uploader = MockUploader::default().with_error_trigger(ErrorTrigger::new(
        ErrorFrequency::Sequence(HashSet::from([1])),
        || UploadError::UploadFailed("network error".to_string()),
    ));
    upload_string(input, &uploader).unwrap();
    assert_eq!(uploader.value(), input);
    assert_eq!(uploader.error_trigger_count(), 1);
}

#[test]
fn test_upload_multiple_transient_fail() {
    let input = "This is a test";
    let uploader = MockUploader::default().with_error_trigger(ErrorTrigger::new(
        ErrorFrequency::Sequence(HashSet::from([1, 4, 7])),
        || UploadError::UploadFailed("network error".to_string()),
    ));
    upload_string(input, &uploader).unwrap();
    assert_eq!(uploader.value(), input);
    assert_eq!(uploader.error_trigger_count(), 3);
}

#[test]
fn test_upload_first_chunk_fail() {
    let input = "This is a test";
    let uploader = MockUploader::default()
        .with_error_trigger(ErrorTrigger::new(ErrorFrequency::Always, || {
            UploadError::UploadFailed("network error".to_string())
        }));
    let e = upload_string(input, &uploader).unwrap_err();
    assert_eq!(e, "network error");
    assert_eq!(uploader.error_trigger_count(), 3);
}

Wrapping up

This example shows how you can inject errors into a mock in a way that is highly reusable. It puts the test condition and logic in the test itself, making each test more readable.

There are many ways to extend this approach by providing more helper methods on the ErrorTrigger struct, and even creating a wider-scoped ErrorPlan struct that can be shared across your whole application, for more complex error scenarios. You also might want to do other things besides return an error, such as simulate delays or other failure modes.

Error injection is a very powerful tool for testing your error paths and improving your test coverage.

Thanks for reading.

Author

  • Steve started learning Rust in 2019. Originally impressed by the performance, he quickly embraced the correctness and expressiveness aspects of the language. He created the thirtyfour browser automation crate, later joining IDVerse in 2021 and working with Rust full-time. When he’s not building out AWS-fuelled applications in Rust at work, he enjoys dabbling in web development with Dioxus and some game development with Bevy.