The Redux architecture we’re using for our application state relies on all our state objects being properly immutable. So far, we’ve relied on nothing more than self-discipline to ensure no mistakes are made. By adding some convention testing to our project, we can enlist some help in avoiding common errors.

As a beginning, let’s declare a simple attribute that we can use to mark each immutable class. We’ll use this attribute to identify the classes that should be immutable and therefore subject to our rules.

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ImmutableAttribute : Attribute
{
}

In addition to the immutable types we declare ourselves, we can use existing types from the .NET library itself. To make it easy to test whether a type is immutable, let’s define an extension method on Type that wraps our desired logic.

public static bool IsImmutableType(this Type type)
{
    if (type is null)
    {
        return false;
    }

    if (type.GetCustomAttribute<ImmutableAttribute>() is object)
    {
        return true;
    }

    if (_systemImmutableTypes.Contains(type))
    {
        return true;
    }

    if (type.IsGenericType && !type.IsGenericTypeDefinition)
    {
        var definition = type.GetGenericTypeDefinition();
        var parameters = type.GetGenericArguments();
        return definition.IsImmutableType()
            && parameters.All(t => t.IsImmutableType());
    }
    
    return false;
}

The logic is relatively simple. If we’ve tagged the type with our new attribute, if it’s a system supplied immutable type, or if it’s a generic type made up of immutable types, then it’s immutable.

Using is object is a tidy way of checking the value isn’t null; I’ve seen this approach emerging in some C# 8.0 code - time will tell whether it becomes a common idiom. I found it odd to read at first, but it’s grown on me.

Not shown, the set _systemImmutableTypes contains standard .NET types we’ve selected as immutable. Currently those types are int, string, bool, and IImmutableSet<>.

We can now define our first test - that all of the properties on an immutable type should themselves be immutable.

[Theory]
[MemberData(nameof(PropertiesOfImmutableTypesToTest))]
public void PropertiesOfImmutableTypesShouldHaveImmutableTypes(
    PropertyInfo property)
{
    property.PropertyType.IsImmutableType()
        .Should().BeTrue(
            $"property {property.Name} should be "
            + "declared as an immutable type");
}

For each property passed into the test, we check that the type of the property is immutable. If not, we fail the test.

Where do those property values come from?

The [MemberData] attribute identifies which member, in our case a method, will be used to supply the data.

public static IEnumerable<object[]> PropertiesOfImmutableTypesToTest()
    => from t in FindDeclaredImmutableTypes()
       from p in t.GetProperties()
       select new object[] { p };

private static IEnumerable<Type> FindDeclaredImmutableTypes()
    => from t in typeof(WordTutorApplication).Assembly.GetTypes()
       where t.IsImmutableType()
       select t;

Starting with [Immutable] on our WordTutorApplication class, we quickly need to add it to VocabularySet, VocabularyWord and Screen for this test to pass.

Our next test checks that all these properties are read-only. We don’t want our immutable types to have any writable properties, after all.

[Theory]
[MemberData(nameof(PropertiesOfImmutableTypesToTest))]
public void PropertiesOfImmutableTypesMustNotBeWritable(
    PropertyInfo property)
{
    property.CanWrite.Should().BeFalse(
        $"property {property.Name} of immutable type "
        + "{property.DeclaringType!.Name} should not be writable");
}

Fortunately, all of our existing classes satisfy this test.

Our next test is to ensure that any subclass of an immutable type is itself marked as immutable.

[Theory]
[MemberData(nameof(TestCasesAreSubclassesOfImmutableTypes))]
public void SubTypesOfImmutableTypesMustBeImmutable(Type type)
{
    type.IsImmutableType().Should().BeTrue(
        $"Type {type.Name} should be marked [Immutable] because it "
        + "descends from immutable type {type.BaseType.Name}.");
}

Again we’re using a [MemberData] attribute to specify where our test data comes from. Finding the subclasses is fairly straightforward:

public static IEnumerable<object[]> TestCasesAreSubclassesOfImmutableTypes()
{
    var immutableTypes = FindDeclaredImmutableTypes().ToHashSet();
    return from t in typeof(WordTutorApplication).Assembly.GetTypes()
        where t.BaseType is Type
            && immutableTypes.Contains(t.BaseType)
        select new object[] { t };
}

Lastly, we want all our immutable types to be explicitly abstract or sealed:

[Theory]
[MemberData(nameof(TestCasesAreImmutableTypes))]
public void ImmutableTypesShouldBeSealedOrAbstract(Type type)
{
    type.Should().Match(t => t.IsAbstract || t.IsSealed);
}

public static IEnumerable<object[]> TestCasesAreImmutableTypes()
    => from t in FindDeclaredImmutableTypes()
       select new object[] { t };

What have we achieved here?

We have a set of simple conventions that we want all our immutable types to adhere to. Instead of stating these in a checklist or some other kind of documentation, we’ve embedded the rules into our test suite making their enforcement an active part of our development process.

Prior post in this series:
Modifying Words, Part the Second
Next post in this series:
Wither convention testing

Comments

blog comments powered by Disqus