Creating objects with conventions

105 views Asked by At

I want to unit test a weather parsing method. My first approach was to let autofixture create a weather object and than create the query response from it. But the weather class contains multiple limitations:

  • Humidity is a percentage value and must be between 1-100
  • Temperatures must be above the minimum depending on the temperature unit

Is it possible to solve this problems and is it worth to use this approach or just hard code a query response and the expected weather object?

1

There are 1 answers

2
Mark Seemann On

As outlined elsewhere, I'd recommend a solution where you let test-driven development provide feedback on your design. Instead of treating humidity and temperature as primitives, refactor to a good domain model. As an example, create a new value object for both:

public struct Humidity
{
    public readonly byte percentage;

    public Humidity(byte percentage)
    {
        if (100 < percentage)
            throw new ArgumentOutOfRangeException(
                nameof(percentage),
                "Percentage must be a number between 0 and 100.");

        this.percentage = percentage;
    }

    public static explicit operator byte(Humidity h)
    {
        return h.percentage;
    }

    public static explicit operator int(Humidity h)
    {
        return h.percentage;
    }

    public override bool Equals(object obj)
    {
        if (obj is Humidity)
            return ((Humidity)obj).percentage == this.percentage;

        return base.Equals(obj);
    }

    public override int GetHashCode()
    {
        return this.percentage.GetHashCode();
    }
}

The type Celcius looks similar:

public struct Celcius
{
    private readonly decimal degrees;

    public Celcius(decimal degrees)
    {
        if (degrees < -273.15m)
            throw new ArgumentOutOfRangeException(
                nameof(degrees),
                "Degrees Celsius must be equal to, or above, absolute zero.");

        this.degrees = degrees;
    }

    public static explicit operator decimal(Celcius c)
    {
        return c.degrees;
    }

    public override bool Equals(object obj)
    {
        if (obj is Celcius)
            return ((Celcius)obj).degrees == this.degrees;

        return base.Equals(obj);
    }

    public override int GetHashCode()
    {
        return this.degrees.GetHashCode();
    }
}

This guarantees that if you have a Humidity or Celcius object, they're valid, because they protect their invariants. This is valuable in your production code, but also provides testing benefits.

Weather simply looks like this, now:

public class Weather
{
    public Humidity Humidity { get; }
    public Celcius Temperature { get; }

    public Weather(Humidity humidity, Celcius temperature)
    {
        this.Humidity = humidity;
        this.Temperature = temperature;
    }
}

You can override Equals and GetHashCode for Weather as well, if you like, but it's not important for this example.

For AutoFixture, you can now define customizations for both types:

public class HumidityCustomization : ICustomization
{
    public void Customize(IFixture fixture)
    {
        fixture.Customizations.Add(new HumidityBuilder());
    }

    private class HumidityBuilder : ISpecimenBuilder
    {
        public object Create(object request, ISpecimenContext context)
        {
            var t = request as Type;
            if (t == null || t != typeof(Humidity))
                return new NoSpecimen();

            var d =
                context.Resolve(
                    new RangedNumberRequest(
                        typeof(byte),
                        byte.MinValue,
                        (byte)100));
            return new Humidity((byte)d);
        }
    }
}

and

public class CelciusCustomization : ICustomization
{
    public void Customize(IFixture fixture)
    {
        fixture.Customizations.Add(new CelciusBuilder());
    }

    private class CelciusBuilder : ISpecimenBuilder
    {
        public object Create(object request, ISpecimenContext context)
        {
            var t = request as Type;
            if (t == null || t != typeof(Celcius))
                return new NoSpecimen();

            var d = 
                context.Resolve(
                    new RangedNumberRequest(
                        typeof(decimal),
                        -273.15m,
                        decimal.MaxValue));
            return new Celcius((decimal)d);
        }
    }
}

You can collect those (and others) in a CompositeCustomization:

public class MyConventions : CompositeCustomization
{
    public MyConventions() : base(
        new CelciusCustomization(),
        new HumidityCustomization())
    {
    }
}

Now you can write tests as simple as this:

[Fact]
public void FixtureCanCreateValidWeather()
{
    var fixture = new Fixture().Customize(new MyConventions());

    var actual = fixture.Create<Weather>();

    Assert.True((int)actual.Humidity <= 100);
    Assert.True(-273.15m <= (decimal)actual.Temperature);
}

This test passes.

Granted, this looks like a lot of work for a single test, but the point is that if you collect all domain-specific customizations in MyConventions, you can reuse that single convention across hundreds of tests, because it guarantess that all domain objects are valid.

Not only does it make your test code more maintainable, it also makes you production code more maintainable.