Composed assertions with Unquote by Mark Seemann
With F# and Unquote, you can write customized, composable assertions.
Yesterday, I wrote this unit test:
[<Theory; UnitTestConventions>] let PostReturnsCorrectResult (sut : TasksController) (task : TaskRendition) = let result : IHttpActionResult = sut.Post task verify <@ result :? Results.StatusCodeResult @> verify <@ HttpStatusCode.Accepted = (result :?> Results.StatusCodeResult).StatusCode @>
For the record, here's the SUT:
type TasksController() = inherit ApiController() member this.Post(task : TaskRendition) = this.StatusCode HttpStatusCode.Accepted :> IHttpActionResult
There's not much to look at yet, because at that time, I was just getting started, and as always, I was using Test-Driven Development. The TasksController class is an ASP.NET Web API 2 Controller. In this incarnation, it merely accepts an HTTP POST, ignores the input, and returns 202 (Accepted).
The unit test uses AutoFixture.Xunit to create an instance of the SUT and a DTO record, but that's not important in this context. It also uses Unquote for assertions, although I've aliased the
test function to
verify. Although Unquote is an extremely versatile assertion module, I wasn't happy with the assertions I wrote.
What's the problem? #
The problem is the duplication of logic. First, it verifies that
result is, indeed, an instance of StatusCodeResult. Second, if that's the case, it casts
result to StatusCodeResult in order to access its concrete StatusCode property; it feels like I'm almost doing the same thing twice.
You may say that this isn't a big deal in a test like this, but in my experience, this is a smell. The example looks innocuous, but soon, I'll find myself writing slightly more complicated assertions, where I need to type check and cast more than once. This can rapidly lead to Assertion Roulette.
The xUnit.net approach #
For a minute there, I caught myself missing xUnit.net's
Assert.IsAssignableFrom<T> method, because it returns a value of type
T if the conversion is possible. That would have enabled me to write something like:
let scr = Assert.IsAssignableFrom<Results.StatusCodeResult> result Assert.Equal(HttpStatusCode.Accepted, scr.StatusCode)
It seems a little nicer, although in my experience, this quickly turns to spaghetti, too. Still, I found myself wondering if I could do something similar with Unquote.
A design digression #
At this point, you are welcome to pull GOOS at me and quote: listen to your tests! If the tests are difficult to write, you should reconsider your design; I agree, but I can't change the API of ASP.NET Web API. In Web API 1, my preferred return type for Controller actions were HttpResponseMessage, but it was actually a bit inconvenient to work with in unit tests. Web API 2 introduces various IHttpActionResult implementations that are easier to unit test. Perhaps this could be better, but it seems like a step in the right direction.
In any case, I can't change the API, so coming up with a better way to express the above assertion is warranted.
Composed assertions #
To overcome this little obstacle, I wrote this function:
let convertsTo<'a> candidate = match box candidate with | :? 'a as converted -> Some converted | _ -> None
(You have to love a language that let's you write
match box! There's also a hint of such nice over
The convertsTo function takes any object as input, and returns an Option containing the converted value, if the conversion is possible; otherwise, it returns None. In other words, the signature of the convertsTo function is
obj -> 'a option.
This enables me to write the following Unquote assertion:
[<Theory; UnitTestConventions>] let PostReturnsCorrectResult (sut : TasksController) (task : TaskRendition) = let result : IHttpActionResult = sut.Post task verify <@ result |> convertsTo<Results.StatusCodeResult> |> Option.map (fun x -> x.StatusCode) |> Option.exists ((=) HttpStatusCode.Accepted) @>
While this looks more verbose than my two original assertions, this approach is more composable.
The really beautiful part of this is that Unquote can still tell me what goes wrong, if the test doesn't pass. As an example, if I change the SUT to:
type TasksController() = inherit ApiController() member this.Post(task : TaskRendition) = this.Ok() :> IHttpActionResult
The assertion message is:
System.Web.Http.Results.OkResult |> Dsl.convertsTo |> Option.map (fun x -> x.StatusCode) |> Option.exists ((=) Accepted) None |> Option.map (fun x -> x.StatusCode) |> Option.exists ((=) Accepted) None |> Option.exists ((=) Accepted) false
Notice how, in a series of reductions, Unquote breaks down for me exactly what went wrong. The top line is my original expression. The next line shows me the result of evaluating
System.Web.Http.Results.OkResult |> Dsl.convertsTo; the result is None. Already at this point, it should be quite evident what the problem is, but in the next line again, it shows the result of evaluating
None |> Option.map (fun x -> x.StatusCode); again, the result is None. Finally, it shows the result of evaluating
None |> Option.exists ((=) Accepted), which is false.
Here's another example. Assume that I change the SUT to this:
type TasksController() = inherit ApiController() member this.Post(task : TaskRendition) = this.StatusCode HttpStatusCode.OK :> IHttpActionResult
In this example, instead of returning the wrong implementation of IHttpActionResult, the SUT does return a StatusCodeResult instance, but with the wrong status code. Unquote is still very helpful:
System.Web.Http.Results.StatusCodeResult |> Dsl.convertsTo |> Option.map (fun x -> x.StatusCode) |> Option.exists ((=) Accepted) Some System.Web.Http.Results.StatusCodeResult |> Option.map (fun x -> x.StatusCode) |> Option.exists ((=) Accepted) Some OK |> Option.exists ((=) Accepted) false
Notice that it still uses a series of reductions to show how it arrives at its conclusion. Again, the first line is the original expression. The next line shows the result of evaluating
System.Web.Http.Results.StatusCodeResult |> Dsl.convertsTo, which is
Some System.Web.Http.Results.StatusCodeResult. So far so good; this is as required. The third line shows the result of evaluating
Some System.Web.Http.Results.StatusCodeResult |> Option.map (fun x -> x.StatusCode), which is
Some OK. Still good. Finally, it shows the result of evaluating
Some OK |> Option.exists ((=) Accepted), which is false. The value in the option was HttpStatusCode.OK, but should have been HttpStatusCode.Accepted.
Unquote is a delight to work with. As the project site explains, it's not an API or a DSL. It just evaluates and reports on the expressions you write. If you already know F#, you already know how to use Unquote, and you can write your assertion expressions as expressive and complex as you want.