I’m a big fan of static typing and I’ve found that using narrow types for each entity in the object model of my programs reduces errors. Rust is particularly well-suited at this task: its lack of implicit type conversions eliminates surprises, and its ownership semantics allow type transformations with zero cost.

Unfortunately, (ab)using narrow types in an app’s domain is really annoying when writing tests. While non-test code rarely instantiates new objects—in the case of a REST service, this would only happen at the service’s boundaries—tests instantiate objects infinitely more times than non-test code. Code patterns that may seem reasonable in non-test code can become unbearable in tests.

In this post, I want to look into the various ways in which you can instantiate strongly-typed objects. For each, I show examples and describe their pros and cons. And yes, as a matter of fact, I have tried them all before… and I can’t yet make my mind as to which one is best.

A blog on operating systems, programming languages, testing, build systems, my own software projects and even personal productivity. Specifics include FreeBSD, Linux, Rust, Bazel and EndBASIC.

0 subscribers

Follow @jmmv on Mastodon Follow @jmmv on Twitter RSS feed

Context-setting

Let me introduce you to the Comment type from the EndTRACKER codebase—a type that represents a textual comment that someone left on a webpage. I’ve simplified it a little for illustration purposes, but the core properties of the type remain:

struct Comment {
    site_id: Uuid,
    path: Url,
    timestamp: OffsetDateTime,
    content: String,

    author: Option<String>,
    email: Option<EmailAddress>,
}

Said core properties are:

  • Four required fields for every comment: site_id, path, timestamp and content.

  • Two optional fields that are only present if the user writing the comment chose to provide them: author and email.

  • Strongly-typed fields. Note that only two of them are raw strings; the rest are narrower types that enforce structure on their values.

    This is important because, for example, while URLs can be represented as strings, they are not strings. The domain of all possible URLs is narrower than the domain of all possible strings because URLs have internal structure. Using a narrow type to represent URLs enforces that, once a URL object exists, we can pass it around and all consumers can assume it has undergone proper validation.

    Note that strong typing can be done in pretty much any language, really, but Rust shines here. It also helps that using narrow types is common practice in this language: in JavaScript, for example, the above would probably have been represented as a loosely-typed Object; and in Python, it would have been shoehorned into a string-to-whatever dictionary.

Let’s look at the ways in which Comment objects can be constructed. Remember that I’m focusing on object creation as part of tests. For this reason, it is not necessary to propagate errors to the caller: panicking internally to fail the test is just fine, which simplifies the design a lot.

Option 1: Struct literals

In the most simple form, a test instantiates a Comment object by specifying all fields:

let comment = Comment {
    site_id,
    path: url!("http://example.com/post.html"),
    timestamp: datetime!(2023-10-03 19:25:00 UTC),
    content: "Irrelevant text".to_owned(),
    author: Some("The Author".to_owned()),
    email: Some("the-email@example.com".into()),
};

Pros:

  • Clarity. It’s painfully obvious what each field contains in every test object.

  • Refactor-proof. When modifying the Comment definition, you are forced to revise all places where the object is constructed. This makes you reassess whether existing tests need to care about the changes or not.

Cons:

  • Lack of conciseness. Not all tests care about all possible fields of a type. Some tests may want to validate ordering, in which case they care about specific timestamp values, whereas other tests may want to check protections against HTML injection, in which case they care about content. But this is not clear in the test because the test is forced to specify values for all fields even if they are irrelevant.

  • Refactoring difficulties. Even though I listed refactoring in the pros, refactoring is also a con. Having to adjust tens or hundreds of tests every time the struct definition changes is a daunting task, particularly when, in general, existing tests do not care about new fields.

Option 2: The Default trait

The standard answer to the cons listed above is to implement Default for the type and thus rely on default values for all fields that are irrelevant in a given context. Ideally, by deriving or implementing Default, we would do something like this:

let comment = Comment {
    site_id,
    path: url!("http://example.com/post.html"),
    ..Default::Default()
};

Unfortunately, this does not work other than for trivial structs. The problem here is that not all types in the Comment struct implement Default, and this problem compounds with any additional type you nest. In this particular example, a Url cannot be empty and an OffsetDateTime does not have a reasonable zero value.

One possible solution to this issue is fabricate fake values for all fields. To do this, you can rely on the derivative crate and use it to supply alternate default values for those fields that don’t have one of their own. It is critical to do this only for debug builds so that these fake definitions never taint production. Here is how this would look like:

#[cfg_attr(test, derive(Derivative))]
#[cfg_attr(test, derivative(Default))]
pub struct Comment {
    #[cfg_attr(test, derivative(Default(value = "Uuid::new_v4()")))]
    pub site_id: Uuid,

    #[cfg_attr(test, derivative(Default(value = r#"url!("https://UNSET/")"#)))]
    pub path: Url,

    #[cfg_attr(test, derivative(Default(value = "OffsetDateTime::UNIX_EPOCH")))]
    pub timestamp: OffsetDateTime,

    #[cfg_attr(test, derivative(Default(value = r#""Irrelevant".to_owned()"#)))]
    pub content: String,

    pub author: Option<String>,
    pub email: Option<EmailAddress>,
}

With this, the above instantiation of a Comment with partial defaults becomes possible.

Pros:

  • Concise and refactor-friendly. Tests can now declare objects with only the few properties they care about. Due to this, adding new fields to the Comment struct does not require modifying the majority of existing tests.

  • Standard. Deriving Default is a common idiom in Rust, so this leads to few surprises.

Cons:

  • Complexity. The type definitions are quite convoluted, particularly due to the need to couple these fake values to debug builds only.

  • Noisy. Having to call ..Default::default() each time we instantiate a struct is annoying and adds a lot of visual noise.

Option 3: Macros

Using macros to instantiate test data is a common idiom in Rust: the macros provide a simpler syntax to instantiate objects and they forcibly unwrap result values because it’s OK to panic tests on invalid data. In fact, note that in the above examples I’ve already used two macros: url! to construct Url values from hardcoded strings assumed to be valid, and datetime! to construct OffsetDateTime values from a readable mini-DSL.

We could define a comment! macro that allowed “keyword-like” arguments to instantiate a test Comment object, like this:

let comment = comment!(
    site_id,
    path: url!("http://example.com/post.html"),
);

Pros:

  • Concise. Same as the Default approach: tests only declare the properties they must declare for the test to pass.

Cons:

  • Refactoring-unfriendly. The Rust auto-formatter does not reformat code inside macros. I rely on this feature too much these days because it’s very liberating to not have to care about manual formatting, so this is a non-option for me.

Option 4: The builder pattern

The next possibility is to use the builder pattern, which I have previously leveraged to define declarative tests. Constructing a Comment would then look something like this and, in fact, that’s what I had in the code for a little while:

let comment = CommentBuilder::new(
    site_id,
    url!("https://example.com/page.html"),
    datetime!(2023-10-03 19:25:00 UTC),
    "Irrelevant text",
)
.unwrap()
.with_author("The author")
.unwrap()
.with_email("the-email@example.com")
.unwrap()
.build();

This usage of a builder is a bit unorthodox and… really ugly: the constructor takes positional arguments for all required fields and the various setters return errors when the input values cannot be converted to the inner types that back them. These design decisions came from the fact that I used this builder in non-test code too, so errors had to be propagated.

But this is not the only way to define a builder. A more traditional application of the builder pattern would result in:

let comment = CommentBuilder::default()
    .site_id(site_id)
    .url(url!("https://example.com/page.html")))
    .timestamp(datetime!(2023-10-03 19:25:00 UTC))
    .content("Irrelevant text")
    .author("The author")
    .email("the-email@example.com")
    .build()
    .unwrap();

This second version of the pattern looks better in general (and you could argue that the original version was a mistake). So let’s analyze this second version.

Pros:

  • Automatic type conversions. The setters can leverage Into and AsRef, making it possible to call them quite naturally without needing to create auxiliary types by hand. Note how, for example, email() takes a string even if the backing type is EmailAddress because the setter accepts Into<EmailAddress> and the type implements From<&'static str> (in test builds only).

  • Conciseness. As is the case for the Default and the macro options, this solution also accepts specifying only the fields that are required for each test. The builder can set defaults for everything else.

Cons:

  • Verbosity. This is no simpler than the Default option and requires non-trivial code to implement the builder itself.

  • Delayed validation. Delaying validation until the call to build() isn’t always easy. Consider email again: if we make the email() setter construct the internal EmailAddress object, then email() has to either return an error (a requirement for production usage) or panic (if the builder is exclusively for tests). But if we try to delay error reporting until build() is called, then the setter cannot leverage Into and needs to either accept strings alone or already-constructed EmailAddress objects.

Option 5: Helper functions

The final possibility is to define helper functions to instantiate our test objects. And if we are defining helper functions, those can do additional work like storing the objects in a database (which is what most of my tests need to do anyway). Here is an example of such a function:

let mut context = TestContext::setup();
// ...
let comment = context.put_comment(
    site_id,
    url!("https://example.com/page.html"),
    datetime!(2023-10-03 19:25:00 UTC),
    "Hello",
    None,
)
.await;

Pros:

  • Extra logic. A helper function can take care of instantiating the test object, but can also perform other operations such as persisting the object. For tests, this can turn out to be very useful in encapsulating sequences of operations.

Cons:

  • Inflexibility. Helper functions sound nice at first because they only take the fewest arguments possible to satisfy the needs of the tests. But things get unwieldy quickly: in the example above, you can already see a wart in the API because there is a confusing None that should probably not be there. This ends happening because different tests need different properties to be set and a single function won’t satisfy them all, so you end up with parameterized helper functions that contain superfluous in the common case, or with multiple helper functions targeted at different test scenarios.

This approach is very tempting to use because declaring new functions is easy and looks simple, but I’ve never ever seen it evolve well long-term in any language. Stay away except for the most trivial cases.

What’s best?

To be honest… I do not know. I have tried all of the above and I’m not completely satisfied with any option. To let you into a secret, the EndTRACKER data model currently contains a mishmash of all these options because I’ve been experimenting with new ideas over time and haven’t yet settled on the one I like the best. (Yes, yes, I know. Consistency should have trumped “prettiness”, but hey, I write side projects because I enjoy exploring different dark corners of my tech of choice.)

Right now my thoughts are these: the builder pattern seems to be the nicest option if you restrict it to tests, because then the builder can encapsulate error unwrapping and callers set all fields by name. However, I’m having a hard time justifying this option in favor of the Default option, because callers look equally complex and the builder option requires a lot of boilerplate to write the builders themselves. And I kinda would like to use the macro option, but the fact that it doesn’t work well with auto-formatting is a deal breaker.

What are your thoughts?