Skip to content

A fluent API for .Net that can enforce architectural rules in unit tests.

License

Notifications You must be signed in to change notification settings

NeVeSpl/NetArchTest.eNhancedEdition

Repository files navigation

net-workflow Nuget

NetArchTest.eNhancedEdition

A fluent API for .Net Standard that can enforce architectural rules in unit tests and create a self-testing architecture. Inspired by the ArchUnit library for Java.

NetArchTest.eNhancedEdition is based on NetArchTest v1.3.2. If you are not familiar with NetArchTest, you should start by reading introduction on Ben's blog.

Rationale

NetArchTest is a well-established mature library, but to push things forward, a few breaking changes had to be made, and that is how eNhancedEdition was born. eNhancedEdition uses almost identical Fluent API as a base library, but it is not 100% backwards compatible, and it will never be.

What eNhancedEdition has to offer, that is not available in the NetArchTest v1.3.2:

revit-database-scripting-update-query

Index

Getting started

The library is available as a package on NuGet: NetArchTest.eNhancedEdition.

Examples

[TestClass]
public class SampleApp_ModuleAlpha_Tests
{
    static readonly Assembly AssemblyUnderTest = typeof(TestUtils).Assembly;

    [TestMethod]
    public void PersistenceIsNotAccessibleFromOutsideOfModuleExceptOfDbContext()
    {
        var result = Types.InAssembly(AssemblyUnderTest)
                          .That()
                          .ResideInNamespace("SampleApp.ModuleAlpha.Persistence")
                          .And()
                          .DoNotHaveNameEndingWith("DbContext")
                          .Should()
                          .NotBePublic()
                          .GetResult();

        Assert.IsTrue(result.IsSuccessful);
    }

    [TestMethod]
    public void DomainIsIndependent()
    {
        var result = Types.InAssembly(AssemblyUnderTest)
                          .That()
                          .ResideInNamespace("SampleApp.ModuleAlpha.Domain")
                          .ShouldNot()
                          .HaveDependencyOtherThan(
                            "System",
                            "SampleApp.ModuleAlpha.Domain",
                            "SampleApp.SharedKernel.Domain",
                            "SampleApp.BuildingBlocks.Domain"
                          )
                          .GetResult();

        Assert.IsTrue(result.IsSuccessful, "Domain has lost its independence!");
    }

}

[TestClass]
public class SampleApp_ModuleOmega_Tests
{
    static readonly Assembly AssemblyUnderTest = typeof(TestUtils).Assembly;

    [TestMethod]
    public void RequestHandlersShouldBeSealed()
    {            
        var result = Types.InAssembly(AssemblyUnderTest)
                          .That()
                          .ImplementInterface(typeof(IRequestHandler<,>))
                          .Should()
                          .BeSealed()
                          .GetResult();

        Assert.IsTrue(result.IsSuccessful);
    }
}

Writing rules

The fluent API should direct you in building up a rule, based on a combination of predicates, conditions and conjunctions.

The starting point for any rule is one of the static methods on Types class, where you load a set of types from an assembly, domain or path.

var types = Types.InAssembly(typeof(MyClass).Assembly);

Once you have loaded the types, you can filter them using one or more predicates. These can be chained together using And() or Or() conjunctions:

types.That().ResideInNamespace("MyProject.Data");

Once the set of types has been filtered, you can apply a set of conditions using the Should() or ShouldNot() methods, e.g.

types.That().ResideInNamespace("MyProject.Data").Should().BeSealed();

Finally, you obtain a result from the rule by using an executor, i.e. use GetTypes() to return the types that match the rule or GetResult() to determine whether the rule has been met.

Note that GetResult() returns TestResult which contains a few lists of types:

  • LoadedTypes - all types loaded by Types
  • SelectedTypesForTesting - types that passed predicates
  • FailingTypes- types that failed to meet the conditions
var result = types.That().ResideInNamespace("MyProject.Data").Should().BeSealed().GetResult();
var isValid = result.IsSuccessful;
var types = result.FailingTypes;

Tip Loading types is time-consuming, since Type class is immutable, its instance can be shared between tests.

Rules for dependency analysis

Dependency matrix:

type\has dependency on D1 D2 D3
a
b x
c x
d x x
e x
f x x
g x x
h x x x

Dependency search:

Rule number
of required
dependencies
from the list
type can have
a dependency
that is not
on the list
passing types failing types
1 HaveDependencyOnAny(D1, D2) at least 1 yes c, d, e, f, g, h, a, b
2 HaveDependencyOnAll(D1, D2) all yes g, h a, b, c, d, e, f
3 OnlyHaveDependencyOn(D1, D2) >=0 no a, c, e, g b, d, f, h
1N NotHaveDependencyOnAny(D1, D2) none yes a, b c, d, e, f, g, h,
2N NotHaveDependencyOnAll(D1, D2) not all yes a, b, c, d, e, f g, h
3N HaveDependencyOtherThan(D1, D2) >=0 yes b, d, f, h, a, c, e, g

An explanation of why a type fails the dependency search test is available on the failing type: IType.Explanation

Reverse dependency search

Predicate number
of required
dependencies
from the list
type can use
a type
that is not
on the list
passing types failing types
R1 AreUsedByAny(c, d) at least 1 yes D2, D3 D1
R1N AreNotUsedByAny(c, d) none yes D1 D2, D3

Rules for assessing design and architectural principles

BeImmutable

A Type is considered as immutable when all its state (instance and static, fields, properties and events) cannot be changed after creation. Shallow immutability.

BeImmutableExternally

A Type is considered as externally immutable when its state (instance and static, fields, properties and events) with a public access modifier cannot be changed from the outside of the type. Shallow immutability.

BeStateless

A Type is considered as stateless when it does not have an instance state (fields, properties and events).

BeStaticless

A Type is considered as stateless when it does not have a static state.

HaveParameterlessConstructor

A type should have a parameterless instance constructor.

DoNotHavePublicConstructor

A type should not have any instance public constructors.

HaveSourceFileNameMatchingName

HaveSourceFilePathMatchingNamespace

HaveMatchingTypeWithName

Slices

var result = Types.InAssembly(typeof(ExampleDependency).Assembly)
                  .Slice()
                  .ByNamespacePrefix("MyApp.Features")
                  .Should()
                  .NotHaveDependenciesBetweenSlices()
                  .GetResult();

There is only one way, at least for now, to divide types into slices ByNamespacePrefix(string prefix) and it works as follows:

  1. Select types which namespace starts with a given prefix, rest of the types are ignored.
  2. Slices are defined by the first part of the namespace that comes right after the prefix: namespacePrefix.(sliceName).restOfNamespace
  3. Types with the same sliceName part will be placed in the same slice. If sliceName is empty for a given type, the type will also be ignored (BaseFeature class from the following image)

Slices

When our types are divided into slices, we can apply the condition: NotHaveDependenciesBetweenSlices(). As the name suggests it detects if any dependency exists between slices. Dependency from slice to type that is not part of any other slice is allowed.

passing failing
Slices Slices

Custom rules

You can extend the library by writing custom rules that implement the ICustomRule interface. These can be applied as both predicates and conditions using a MeetsCustomRule() method, e.g.

var myRule = new CustomRule();

// Write your own custom rules that can be used as both predicates and conditions
var result = Types.InCurrentDomain()
    .That()
    .AreClasses()
    .Should()
    .MeetCustomRule(myRule)
    .GetResult()
    .IsSuccessful;

Options

User Options allows to configure how NetArchTest engine works.

var result = Types.InCurrentDomain()
    .That()
    .ResideInNamespace("NetArchTest.TestStructure.NameMatching.Namespace3")
    .Should()
    .HaveNameStartingWith("Some")
    .GetResult(Options.Default with { Comparer = StringComparison.Ordinal});

Assert.True(result.IsSuccessful);

Available options:

  • Comparer - allows to specify how strings will be compared, default: InvariantCultureIgnoreCase (it only affects: Predicate.HaveName, Predicate.HaveNameStartingWith, Predicate.HaveNameEndingWith)

  • SerachForDependencyInFieldConstant - determines if dependency analysis should look for dependency in string field constant, default: false

Limitations

NetArchTest is built on top of jbevain/cecil thus it works on CIL level. Unfortunately, not every feature of C# language is represented in CIL, thus some things will never be available in NetArchTest, e.g.: