While working on the visn
eventual consistency testing framework for the qaul.net project, I’ve run into an excellent example of one of the most important reasons to test software, in some ways more important than the discovery of regressions, design defects, or other functional issues. Specifically, the ability to determine problems in the conceptual model around which the software is built.
Conceptual Testing
Unit, integration, and acceptance tests are well known for their value in detecting regressions, ensuring that functions, classes, and other units are written in a self-contained and composable style, and ensuring that design goals are met throughout the lifecycle of the project.
In statically typed languages like Rust, however, it can often be tempting to eschew the fine-grained level of unit testing used in dynamic languages, since the compiler checks many of the constraints unit tests are designed to impose. Rust, for example, permits encoding a lot of detail about the presence, or absence, of values with the type system.
In qaul.net’s libqaul
, we provide a model for metadata about a user in the UserData
struct (from libqaul/src/users/mod.rs
):
/// A public representation of user information
///
/// This struct is used for both the local user (identified
/// by `UserAuth`) as well as remote users from the contacts book.
#[derive(Default, Debug, PartialEq, Clone)]
pub struct UserData {
/// A human readable display-name (like @foobar)
pub display_name: Option<String>,
/// A human's preferred call-sign ("Friends call me foo")
pub real_name: Option<String>,
/// A key-value list of things the user deems interesting
/// about themselves. This could be stuff like "gender",
/// "preferred languages" or whatever.
pub bio: BTreeMap<String, String>,
/// The set of services this user runs (should never be empty!)
pub services: BTreeSet<String>,
/// A users profile picture (some people like selfies)
pub avatar: Option<Vec<u8>>,
}
This struct provides Option
al types for every field, except for those fields which can contain nothing (like the BTreeMap
or BTreeSet
), since by design, a user may not have set any of these fields. This works really well for user storage, which was the original purpose of the data structure, but does not work well for user information transmission, as I found out.
Conceptual Problems in the User Model
Initially, libqaul
was designed to use the UserData
struct for all user data needs, including transmission. Some of the UserData
related API surface was the first that I implemented during the initial deployment of visn
, and therefore the first API surface to be tested. During that process, I mapped the function Qaul::user_update()
to a visn
synthetic event, UserUpdate
, which carried a UserData
to be passed to user_update()
.
While writing these tests, I encountered a problem: what happens in the case that a user wants to clear a field in their UserData
? Do they issue a UserData
in which there is an Option::None
value in that field (like a null
), which is interpreted to mean that the field should be cleared?
This made the user_update()
function very easy to implement, since it could simply assign the newly received UserData
as the new canonical UserData
for that user. That, however, leads to a problem when it comes to data transmission over the actual network. When, for example, a user has set a profile photo or a lot biography fields, the UserData
could be pretty large, and retransmitting that on every subsequent update is not very practical.
The act of writing these tests, which were primarily designed to prevent regressions, lead me to implement a delta-based UserData
update scheme, wherein the UserData
is updated incrementally with small changes. This provides other advantages, too, such as allowing more orderings of those events’ arrivals to result in a valid state for the UserData
.
Conceptual Problems in visn
In addition to uncovering problems in the design of libqaul
, this process helped me refine my ideas for the visn
testing framework. Initially, visn
assumed that all operations modelled by synthetic events were infallible, or at least that failure to perform an action should lead to test failure. In fact, a critical component of eventually consistent systems is their ability to reject invalid states, in order to remain robust in the face of serious network problems or malicious input.
Originally, the resolve
function took the state of the system under test and an event, and returned the new state (Fn(Event, System) -> System
).
To address this problem, visn
‘s type model became even more complex, incorporating a separate return type rather than requiring that the function which resolves events always return a successfully transformed state (Fn(Event, System) -> Return
), and the infallible variant now simply sets Return
to System
.
In addition, rather than taking a singular System
argument when resolving events, visn
now takes a function returning a System
, laying the groundwork for supporting multiple permutations of the ordering of queued events.
Conclusion
Testing is important for both compiled and dynamic languages to prevent defects and enforce good factorization, but the benefits to compiled languages can, like many design processes, be moved “left”, into the pre-execution step. As seen here, the simple act of writing tests often leads to conflict with the type system and compiler that can reveal conceptual and design defects in the system being tested.