My Introduction to Test Driven Development and How Theory Became Practice
For years I have been following the discussions and lectures on testing an Umbraco based website. Although the arguments have always convinced me how helpful automated tests can be, I struggled to use tests in real projects.
A few projects that could serve as typical examples of code that is difficult or difficult to maintain have once again shown me the usefulness of clean code and TDD. And I delved deeper into the subject. I personally recommend the books by Robert C. Martin and his son.
When Dennis Adolfi published his Umbraco 9 project https://github.com/Adolfi/UmbracoNineDemoSite with test examples in 2021, I took the opportunity to devote myself to the test topic.
Dennis recreated the HQ starter kit based on his practical experience and focused on testing. But he did not take the ModelsBuilder approach into account.
Personally, I am a big fan of the ModelsBuilder and have therefore made it my task to implement a corresponding variant.
The following is a step-by-step demonstration of my approach. I have provided the individual steps in the corresponding branches at https://github.com/idseefeld/UmbracoNineDemoSite.
General Information About Unit Tests
For this demo I use the frameworks NUnit and Moq. A unit test is intended to ensure that the method to be tested behaves correctly and delivers the expected results.
There are three steps to be distinguished:
- Setup (Arrange): This is where data and other prerequisites are defined to be able to call the method in question. It is important to simulate dependencies that are required in the most neutral way possible without side effects. The Moq framework is used for this. Only one method should be tested and not its entire context.
- Execution (Act): The method is called with the data and objects from the setup.
- Review of results (Assert): The results are then compared to the expected values and a test is passed if they match.
Test Driven Development (TDD)
While tests can generally be written after the actual implementation, it often turns out that testing that implementation can be quite difficult due to the way it was built. You know – there are several roads to Rome.
The idea of Test-Driven Development (TDD) solves this issue by reversing this process. Start writing tests and then implement the real tested feature until all tests pass without failure. This encourages careful thought about what is expected of the feature and helps to find an implementation that is testable from the start.
By the way, a good argument for writing tests is the documentation. Concrete examples with realistic parameters and result validation explain a feature (method) much better than any eloquent, theoretical descriptions could do – at least in my opinion.
NUnit
In the Nunit framework, a test is defined by the [Test] attribute before a method declaration. Other attributes such as [TestCase(...)] can be used to test different input parameters.
The method names should describe what the three steps of the test do. These are each separated by the keywords Given, When and Then. This makes Test Explorer easy to see what is being tested. But this is only best practice. Since these are normal object methods, you can choose any name.(I'm following Dennis' preferences here, as this is his initial project. In general, I think it makes sense for a team to agree on certain conventions, including naming, code formatting, etc.)
Within a test method, the class is instantiated with the method to be tested and the method is called.
With the help of the NUnit Assert class or its static methods (e.g.: Assert.True(result)), the result is compared with expected values.
Moq
The Moq framework is used for dependencies such as simulating services, databases etc. without using actual instances. Mock objects are created for this. The required properties and results of method calls are defined for these. Only the properties and methods required by the method to be tested need to be taken into account.
First Example - ContentFinder
The Product Area as an Example of External Content
The products should be loaded from an external source but treated like real nodes in Umbraco. The IContentFinder implementation ProductsContentFinder is used for this. Simply put, IContentFinder serves to provide content in the form of IPublishedContent objects for a given URL. Testing this ContentFinder is the subject of the following explanations.
What is to be Tested?
A ContentFinder returns true or false and saves the content found in the transferred request object. Its only method is defined in the IContentFinder interface as follows:
public bool TryFindContent(IPublishedRequestBuilder request)
This obviously results in a test for true or false depending on the input. A corresponding test forms my starting point.
The original implementation of the ProductsContentFinder : IContentFinder relies on XPath methods. After I transferred the implementation from the XPath approach to a ModelsBuilder based one, the associated test no longer worked. Why that was so will become clearer during my further explanations.
https://github.com/idseefeld/UmbracoNineDemoSite/tree/modelsbuilder-brocken-test
Implementation of the Tests
First, we have ProductsContentFinder.
For this we create ProductsContentFinderTest.
From my ModelsBuilder based implementation, there was a dependency on the three interfaces:
- IProductService
- IUmbracoContextAccessor
- IPublishedSnapshotAccessor
All three are simulated (mocked) in the test using the Moq framework, which is an essential part of the test setup. In addition, an IPublishedRequestBuilder request object is required, which is passed to the TryFindContent method as a parameter and, if necessary, accepts the content found.
The three delegates are used in Moq’s callback mechanism to set return values. I define string constants as centrally as possible and only once.
We next create our request mock. As already described, an IContentFinder determines content based on URLs or path segments. Therefore, we need to set the AbsolutePathDecoded property for our request mock. For this we use the parameters from the TestCase attribute.
The request object is also used to hold the found IPublishedContent object. To be able to check this in our test, we need Moq's callback mechanism. For this purpose, I have defined a delegate method ServiceSetPublishedContent in my test class. This is used in the request mock for the SetPublishedContent method (see line 55 of the ProductsContentFinder.TryFindContent method) to take the found content and store it in a variable for later evaluation.
As can be seen in line 33 of the ProductsContentFinder.TryFindContent method, only the Get(int id) method of the IProductService class is used in the ProductsContentFinder and only these I define in the IProductService mock.
The get method returns an IProduct object and we also provide this as a mock. So that I can make sure in the verification part of the test that I get this IProduct mock back, I set the Id and Name properties.
Now we come to the most complex setup in this test... the IUmbracoContextAccessor mock.
First, let's look at the usage in the ProductsContentFinder:
Apart from the visual breaks, there are two lines. But they have it all. And it is precisely this solution that prompted me to write this article.
To get straight to the point, my test setup requires 19 statements (line with a semicolon at the end). With optically - for me - appealing formatting, there are 41 lines:
Let's start with the line:
umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext umbracoContext);
Looks easy at first glance but wait! An out parameter?
The first attempt used an IUmbracoContext mock for the out parameter. However, the compiler did not want to go along with this:
My internet research finally led me to Moq's callback method.
In fact, I had to search them first for this problem. For the request mock already discussed, I originally did not return the found content.
But also, the callback solution required another mock object namely for: IUmbracoContext
That brings us to three statements for the IUmbracoContextAccessor mock, if it weren't for the second line in the ProductsContentFinder, which uses the IUmbracoContext mock for said out parameter.
This takes the GetByContentType method of the Content property of the IUmbracoContext mock. The Content property, in turn, requires an IPublishedContentCache mock.
Furthermore the GetByContentType method takes an IPublishedContentType mock as a parameter and an IEnumerable<IPublishedContent> object using a ProductsContainer mock as the return value.
This ProductsContainer mock requires the GetModelContentType method to be defined and return the exact IPublishedContentType mock used in umbracoContext?.Content.GetByContentType(contentType).
Finally, new Mock<ProductsContainer>() needs two simple mocks called Mock.Of<IPublishedContent>() and Mock.Of <IPublishedValueFallback>() which can be generated.
All these dependencies resulted from the backward analysis of the objects used and their methods.
We then create the IPublishedSnapshotAccessor mock. The only thing to note about this mock is that it uses the same IPublishedContentCache mock from the previous section.
With all these mocks and their internal dependencies, instantiating the ProductsContentFinder class and calling its TryFindContent method needs no further explanation.
What remains is the final evaluation of the return values. The direct boolean is simple with Assert.True(result).
Much more interesting is the checking of the found content, which only became possible with the first use of a callback method.
A final look at the Test Explorer window rewards us with a successfully executed test:
The result of my efforts so far can be found in the branch: https://github.com/idseefeld/UmbracoNineDemoSite/tree/modelsbuilder-step1
More Tests
Now that the first test case for the ProductsContentFinder class has been adapted to the ModelsBuilder implementation, I would like to go into further cases and thus build the bridge to TDD. Previously, a class was implemented first and then a test was written. With TDD, however, the process is exactly the opposite. I still find it difficult to change my mindset myself, but I'll take this starting point to switch to TDD.
Because I noticed something in the previous work and this time I'll start with a test in order to then fix the implementation:
I copy the existing test and rename the method:
Given_RequestContainsWrongRootSegment_When_TryFindContent_Then_ExpectFalse
I also use as TestCase attributes: [TestCase("xxx", 123, "any")]
Inside the test method, I just change the assert section:
Assert.False(result);
Assert.IsNull(dummyContent);
This time I expect that the RootPath parameter will be checked and only if the URL starts with /products/ will it find a matching Umbraco content node and return true. This test fails. Obviously the RootPath segment is not checked.
Before I do the necessary fix to the TryFindContent method, I'll throw in a refactoring of the Test class because I noticed that I've reused a significant portion of the code unmodified.
Refactoring
With the Extract method in Visual Studio 2022, refactoring is done quickly. The ProductsContentFinderTests class now contains a private method that can be used by either test and contains all the setup, instantiation, and invocation of TryFindContent.
Correction of the Implementation
The solution to the identified problem is simple:
if(!segments[1].Equals(container.UrlSegment)){ return false; }
However, a new test run shows that the first test fails:
A debug session shows us that the container.UrlSegment property is null. Sure, we haven't included them in our mock setup yet. Glad we pushed in the refactoring. Because now we only have to add the following:
productsContainer.Setup(s => s.UrlSegment).Returns("products");
What happens if we pass a non-numeric value as ProductId? Another test...
It turns out that the previous test methods contain an error. They assume that ProductId is an integer and do not allow any other test cases. However, URLs are basically text (string) type. In addition, the id in the mock is already equated with the test parameter, which also does not allow testing wrong ids.
This is the point at which you can enter.
Have fun with Clean Coding and testing!
You can find my solution at: https://github.com/idseefeld/UmbracoNineDemoSite/tree/modelsbuilder-step2