Thursday, 21 September 2017

NExpect level 3: you are the key component

In previous posts, I've examined how to do simple and collection-based assertions with NExpect.

These have enabled two of the design goals of NExpect:
  • Expect(NExpect).To.Be.Readable();
    • Because code is for co-workers, not compilers. And your tests are part of your documentation.
  • Expect(NExpect).To.Be.Expressive();
    • Because the intent of a test should be easy to understand. The reader can delve into the details when she cares to.

Now, we come on to the third goal, inspired by Jasmine: easy user extension of the testing framework to facilitate expressive testing of more complex concepts.

Most of the "words" in NExpect can be "attached to" with extension methods. So the first question you have to ask is "how do I want to phrase my assertion?". You could use the already-covered .To or .To.Be:

internal static class Matchers
{
  internal static void Odd(
    this IBe be
  )
  {
    be.AddMatcher(actual =>
    {
      var passed = actual % 2 == 1;
      var message = passed
                    ? $"Expected {actual} not to be odd"
                    : $"Expected {actual} to be odd";
      return new MatcherResult(
        passed,
        message
      );
    });
  }
}

The above extension enables the following test:

  [Test]
  public void ILikeOddNumbers()
  {
    // Assert
    Expect(1).To.Be.Odd();
    Expect(2).Not.To.Be.Odd();
  }

There are a few concepts to unpack:

.AddMatcher()

This is how you add a "matcher" (term borrowed from Jasmine... Sorry, I couldn't come up with a better name, so it stuck) to a grammar continuation like .To or .Be. Note that we just create an extension method on IBe<T> where T is the type you'd like to test against, and the internals of that extension basically just add a matcher. This takes in a Func<T, IMatcherResult> so your actual matcher needs to return an IMatcher result, which really is just a flag about whether or not the test passed and the message to display if the test failed.

Pass or fail?

This is the heart of your assertion and can be as tricky as you like. Obviously, your matcher could also have multiple exit strategies with specific messages about each possible failure. But the bit that takes a little getting used to is that you're writing a matcher which could be used with .Not in the grammar chain, so you should cater for that eventuality.

Meaningful messages

There's a simple strategy here: get the passed value as if you're doing a positive assertion (ie, as if there is no .Not in the chain) and set your message as follows:
  • If you've "passed", the message will only be shown if the expectation was negated (ie, there's a .Not in the chain), so you need to negate your message (see the first message above)
  • If you've "failed", the message will only be show if the message was not negated, so you need to show the relevant message for that scenario.
It turns out that (mostly), we can write messages like so:

internal static class Matchers
{
  internal static void Odd(
    this IBe be
  )
  {
    be.AddMatcher(actual =>
    {
      var passed = actual % 2 == 1;
      var message = 
        $"Expected {actual} {passed ? "not " : ""}to be odd";
      return new MatcherResult(
        passed,
        message
      );
    });
  }
}

Doing this is tedious enough that NExpect offers a .AsNot() extension for booleans:

internal static class Matchers
{
  internal static void Odd(
    this IBe be
  )
  {
    be.AddMatcher(actual =>
    {
      var passed = actual % 2 == 1;
      var message = 
        $"Expected {actual} {passed.AsNot()}to be odd";
      return new MatcherResult(
        passed,
        message
      );
    });
  }
}

Also, NExpect surfaces a convenience extension method for printing values: .Stringify() which will:
  • print strings with quotes
  • null values as "null" 
  • and objects and collections in a "JSON-like" format. 
Use as follows:

internal static class NumberMatchers
{
  internal static void Odd(
    this IBe be
  )
  {
    be.AddMatcher(actual =>
    {
      var passed = actual % 2 == 1;
      var message = 
        $"Expected {actual.Stringify()} {passed.AsNot()}to be odd";
      return new MatcherResult(
        passed,
        message
      );
    });
  }
}

You'll have to think (a little) about your first matcher, but it starts getting easier the more you write (:
Now you can write more meaningful tests like those in the demo project.

The above is fine if, like me, you can see a pattern you'd like to test for really easily (if you have a kind of "matcher-first" mindset), but does provide a minor barrier to entry for those who like to write a few tests and refactor out common assertions.

Not to worry: NExpect has you covered with .Compose():

internal static class PersonMatchers
{
  internal static void Jane(
    this IA a
  )
  {
     a.Compose(actual =>
     {
        Expect(actual.Id).To.Equal(1);
        Expect(actual.Name).To.Equal("Jane");
     });
  }

// ....
  [Test]
  public void TestJane()
  {
    // Arrange
    var person = new Person() { Id = 1, Name = "Jane", Alive = true };

    // Assert
    Expect(person).To.Be.A.Jane();
  }
}

.Compose uses [CallerMemberName] to determine the name of your matcher and attempts to throw a useful UnmetExpectationException when one of your sub-expectations fails. You may also provide a Func to .Compose to generate a more meaningful message.

These are some rather simple examples -- I'm sure you can get much more creative! I know I have (:

Some parts of NExpect are simply there to make fluent extension easier, for example:
  • To.Be.A
  • To.Be.An
  • To.Be.For
NExpect will be extended with more "grammar parts" as the need arises. If NExpect is missing a "grammar dangler" that you'd like, open an issue on GitHub.
 

No comments:

Post a Comment

#PeanutButter.Utils: the dictionaries Dictionaries, HashMaps, whatever you want to call them -- they can be one of the most useful constru...