This post builds further on all the things I discussed in the previous topics in this series. If you've read my article 'Refining content editing experiences in Umbraco with a user-focussed approach', then you might have thought: "that's a lot of rules and I don't want to manually check all these things". That was my thought as well, so I went ahead and tried to see if I could turn those rules into automated tests. Here's how I did it:
Before you start
Before you use these tests, you'll need to have these things set up:
- An IntegrationTestBase, similar to the one in the first blog post of this series.
- uSync with import on first boot. Once again, it's explained in the first blog in this series. You need this enabled so your website is populated with the content that you want to test.
Arranging the test
Most of my rules apply to document types, so let's build some tests for document types. We start by creating a new test class and a new test function and getting a list of all document types. This test is created using xUnit and FluentAssertions:
ContentTests.cs
public class ContentTests : IntegrationTestBase
{
public ContentTests(TestWebsiteCollectionFixture testWebsiteCollection)
: base(testWebsiteCollection)
{
}
[Fact]
public async Task AllUmbracoContentSettingsAreConfiguredCorrectlyAsync()
{
// arrange
// 👇 Ensure the website is up-and-running.
// After preparation, uSync will have imported all content into the in-memory database and we should have a fully operational copy of our website.
await PrepareAsync();
// 👇 Since there is a variety of things that we want to validate, we use a factory method to produce all the checks that we want to run.
var propertyChecks = GetPropertyChecks();
// 👇 The tests will require document types, but also translations from the translation section,
// so we grab a reference to the relevant services here
var documentTypeService = ServiceProvider.GetRequiredService<IContentTypeService>();
var localizationService = ServiceProvider.GetRequiredService<ILocalizationService>();
var globalSettings = ServiceProvider.GetRequiredService<IOptions<GlobalSettings>>();
var defaultUILanguage = globalSettings.Value.DefaultUILanguage;
var languages = localizationService.GetAllLanguages();
// 👇 Use the document type service to get a list of all document types. These are our test subjects for the rest of the test
var documentTypes = documentTypeService.GetAll();
// act
// ...
}
private IReadOnlyCollection<ContentCheckBase<PropertyDetails>> GetPropertyChecks()
{
// This method will be implemented later in this article
// ...
}
}
Now that we have a list of all our document types, we can loop over them and perform our tests:
ContentTests.cs
// act
// 👇 Using an assertion scope allows us to perform all checks before throwing an error.
// We'll want this so we can see all mistakes at once, rather than one-at-a-time.
using (new AssertionScope())
{
foreach (var documentType in documentTypes)
{
// 👇 We want to check all properties on this document type, this line fetches all properties
var allPropertyTypes = documentType.NoGroupPropertyTypes.Concat(documentType.PropertyGroups.SelectMany(pg => pg.PropertyTypes ?? Enumerable.Empty<IPropertyType>()));
foreach (var property in allPropertyTypes)
{
// 👇 A property might have a translatable component as a title and/or description. Using the localization service, we can fetch the corresponding translations.
IDictionaryItem? nameItem = null;
string name = property.Name;
if (name.StartsWith('#'))
{
nameItem = localizationService.GetDictionaryItemByKey(name[1..]);
}
IDictionaryItem? descriptionItem = null;
string? description = property.Description;
if (description?.StartsWith('#') is true)
{
descriptionItem = localizationService.GetDictionaryItemByKey(description[1..]);
}
// 👇 If a property is translated in multiple languages, we'll want to validate it for each language.
List<PropertyDetails> versions = new ();
if (nameItem is not null || descriptionItem is not null)
{
foreach (var lang in languages)
{
string? actualName = nameItem is not null ? nameItem?.GetTranslatedValue(lang.Id) : name;
string? actualDescription = descriptionItem is not null ? descriptionItem?.GetTranslatedValue(lang.Id) : description;
versions.Add(new PropertyDetails(lang.IsoCode, actualName, actualDescription, property, documentType));
}
}
else
{
versions.Add(new PropertyDetails(defaultUILanguage, name, description, property, documentType));
}
// 👇 At this point, we have all property versions that we want to validate, so we're free to run through all the checks that we want to perform.
foreach (var prop in versions)
{
foreach (var check in propertyChecks)
{
// assert
await check.ValidateContentAsync(prop);
}
}
}
}
}
PropertyDetails.cs
public record PropertyDetails(string Culture, string? Name, string? Description, IPropertyType Property, IContentType ContentType);
That's how our test is going to run. The next step is to create the rules that we want to validate the properties against.
Building the rules
You're free to implement GetPropertyChecks
any way you like and I would encourage you to do so. In this article, I'll show you one possible way to set it up: using a builder.
I separated the checks into two concepts: Rules and Requirements. A rule is what I want to validate. A rule makes the test fail or succeed. A requirement, on the other hand, decides whether a rule should be applied or not. Together, they form a convenient interface that allows me to build rules with relative ease.
The example will perform two checks:
- All properties should have a description, except editor notes and rich text editors that hide the label.
- All descriptions of optional properties should be prefixed with (optional)
Requirements
A requirement answers the question: "Should this check be performed on the given value?". In order to perform all the checks, we'll need several requirements:
- A property type requirement
- An optional property requirement
- An 'rte hides the label' requirement
The requirements all implement a common interface:
IContentRequirementBase
internal interface IContentRequirementBase<T>
{
bool IsRequirementMet(T content);
}
The requirements are implemented as follows:
UsesPropertyEditor.cs
internal sealed class UsesPropertyEditor : IContentRequirementBase<PropertyDetails>
{
private readonly string _propertyEditorAlias;
private readonly bool _invert;
public UsesPropertyEditor(string propertyEditorAlias, bool invert = false)
{
_propertyEditorAlias = propertyEditorAlias;
_invert = invert;
}
public bool IsRequirementMet(PropertyDetails content)
{
// 👇 'invert' may turn the requirement from 'should be' to 'should not be'.
bool shouldBe = !_invert;
return content.Property.PropertyEditorAlias.Equals(_propertyEditorAlias, StringComparison.OrdinalIgnoreCase) == shouldBe;
}
}
IsOptional.cs
internal sealed class IsOptional : IContentRequirementBase<PropertyDetails>
{
public bool IsRequirementMet(PropertyDetails content)
{
return !content.Property.Mandatory;
}
}
RteHidesLabel.cs
internal sealed class RteHidesLabel : IContentRequirementBase<PropertyDetails>
{
private readonly IDataTypeService _dataTypeService;
private readonly bool _invert;
public RteHidesLabel(IDataTypeService dataTypeService, bool invert = false)
{
_dataTypeService = dataTypeService;
_invert = invert;
}
public bool IsRequirementMet(PropertyDetails content)
{
// 👇 This requirement only applies to rich text editor properties, so we need to make sure that the data type is a rich text editor
if (content.Property.PropertyEditorAlias is not "Umbraco.TinyMCE") return true;
// 👇 Use the datatype service to find the rich text editor settings
var dataType = _dataTypeService.GetDataType(content.Property.DataTypeId);
if (dataType is null) return true;
// 👇 Finally read the config from the rich text editor to find out whether or not the label has been hidden
var rteConfig = dataType.ConfigurationAs<RichTextConfiguration>();
if (rteConfig is null) return true;
bool shouldBeHidden = !_invert;
return rteConfig.HideLabel == shouldBeHidden;
}
}
These requirements are enough to implement our content checks. Next up are the rules:
Rules
All rules work the same: Verify the requirements, then perform the check. We'll capture this in a base class:
ContentCheckBase
internal abstract class ContentCheckBase<T>
{
private readonly IReadOnlyCollection<IContentRequirementBase<T>> _requirements;
protected ContentCheckBase(IReadOnlyCollection<IContentRequirementBase<T>> requirements)
{
_requirements = requirements;
}
public ValueTask ValidateContentAsync(T content)
{
// 👇 1) Verify requirements
if (!_requirements.All(r => r.IsRequirementMet(content))) return ValueTask.CompletedTask;
// 👇 2) Perform the check
return DoValidateContentAsync(content);
}
protected abstract ValueTask DoValidateContentAsync(T content);
}
Now we'll implement the two rules:
PropertyHasNameAndDescriptionCheck.cs
internal sealed class PropertyHasNameAndDescriptionCheck : ContentCheckBase<PropertyDetails>
{
public PropertyHasNameAndDescriptionCheck(IReadOnlyCollection<IContentRequirementBase<PropertyDetails>> requirements)
: base(requirements)
{
}
protected override ValueTask DoValidateContentAsync(PropertyDetails content)
{
content.Name.Should().NotBeNullOrWhiteSpace(because: "\"{0}\" ({1}) in \"{2}\" should have a name in {3}", content.Name, content.Property.Alias, content.ContentType.Name, content.Culture);
content.Description.Should().NotBeNullOrWhiteSpace(because: "\"{0}\" ({1}) in \"{2}\" should have a description in {3}", content.Name, content.Property.Alias, content.ContentType.Name, content.Culture);
return ValueTask.CompletedTask;
}
}
PropertyOptionalPrefixCheck.cs
internal sealed class PropertyOptionalPrefixCheck : ContentCheckBase<PropertyDetails>
{
private static readonly Dictionary<string, string> _cultureToPrefixMap = new Dictionary<string, string>()
{
["nl-NL"] = "(Optioneel) ",
["en-US"] = "(Optional) "
};
public PropertyOptionalPrefixCheck(IReadOnlyCollection<IContentRequirementBase<PropertyDetails>> requirements)
: base(requirements)
{
}
protected override ValueTask DoValidateContentAsync(PropertyDetails content)
{
var prefix = _cultureToPrefixMap[content.Culture];
content.Description.Should().StartWith(prefix, because: "\"{0}\" ({1}) in \"{2}\" is optional", content.Name, content.Property.Alias, content.ContentType.Name);
return ValueTask.CompletedTask;
}
}
Those are the rules. As you can see, each individual component is quite simple, but combined together, they can form a sophisticated content check.
We could stop here and simply instantiate all our requirements and rules by hand. However, I personally find that that gets very tiresome very quickly and the code gets difficult to read. To improve the developer experience, we can combine these components using a builder:
Builder
By using a builder, we can make it easier to instantiate rules and requirements by leveraging dependency injection for example:
ContentCheckBuilder.cs
internal static class ContentCheckBuilder
{
// 👇 A static method provides a convenient starting point for building content checks.
// We simply instantiate a new builder and return it by its interface, so we can build against the interface.
public static IContentCheckBuilder<TCheck, TContent> Create<TCheck, TContent>(IServiceProvider serviceProvider)
where TCheck : ContentCheckBase<TContent>
=> new ContentCheckBuilder<TCheck, TContent>(serviceProvider);
}
// 👇 The interface defines how we build our content check
internal interface IContentCheckBuilder<TCheck, TContent>
where TCheck : ContentCheckBase<TContent>
{
// 👇 Option 1: automatically instantiate a check with the given parameters
IContentCheckBuilder<TCheck, TContent> WithRequirement<TRequirement>(params object[] parameters)
where TRequirement : IContentRequirementBase<TContent>;
// 👇 Option 2: create the requirement manually and pass it by hand
IContentCheckBuilder<TCheck, TContent> WithRequirement(IContentRequirementBase<TContent> requirement);
ContentCheckBase<TContent> Go();
}
internal sealed class ContentCheckBuilder<TCheck, TContent>
: IContentCheckBuilder<TCheck, TContent>
where TCheck : ContentCheckBase<TContent>
{
private readonly List<IContentRequirementBase<TContent>> _requirements;
private readonly IServiceProvider _serviceProvider;
public ContentCheckBuilder(IServiceProvider serviceProvider)
{
_requirements = new List<IContentRequirementBase<TContent>>();
_serviceProvider = serviceProvider;
}
public IContentCheckBuilder<TCheck, TContent> WithRequirement<TRequirement>(params object[] parameters)
where TRequirement : IContentRequirementBase<TContent>
{
// 👇 'ActivatorUtilities' is a utility from microsoft that makes it easier to instantiate objects of a given type, using a service provider to provide constructor parameters.
// This is the part that allows us to use dependency injection inside a requirement
return WithRequirement(ActivatorUtilities.CreateInstance<TRequirement>(_serviceProvider, parameters));
}
public IContentCheckBuilder<TCheck, TContent> WithRequirement(IContentRequirementBase<TContent> requirement)
{
_requirements.Add(requirement);
return this;
}
public ContentCheckBase<TContent> Go()
{
// 👇 Once again, use the activator utilities to create an instance of the check.
// This allows us to use dependency injection inside a rule
return ActivatorUtilities.CreateInstance<TCheck>(_serviceProvider, _requirements);
}
}
Now we have the tools to implement the GetPropertyChecks
method.
Combined
Using the builder, we can implement the GetPropertyChecks
as follows:
private IReadOnlyCollection<ContentCheckBase<PropertyDetails>> GetPropertyChecks()
{
// 👇 Some requirements are the same for multiple rules, so we instantiate them here for reuse.
var doesNotUseEditorNotes = new UsesPropertyEditor("Umbraco.Community.Contentment.EditorNotes", invert: true);
var doesNotUseNotes = new UsesPropertyEditor("Umbraco.Community.Contentment.Notes", invert: true);
return new[]
{
// 👇 The builder creates a check and assigns requirements to it
ContentCheckBuilder.Create<PropertyHasNameAndDescriptionCheck, PropertyDetails>(ServiceProvider)
.WithRequirement(doesNotUseEditorNotes)
.WithRequirement(doesNotUseNotes)
.WithRequirement<RteHidesLabel>(true)
.Go(),
ContentCheckBuilder.Create<PropertyOptionalPrefixCheck, PropertyDetails>(ServiceProvider)
.WithRequirement<IsOptional>()
.WithRequirement(doesNotUseEditorNotes)
.WithRequirement(doesNotUseNotes)
.WithRequirement<RteHidesLabel>(true)
.WithRequirement(new UsesPropertyEditor(Constants.PropertyEditors.Aliases.Boolean, invert: true))
.Go()
};
}
Result
Let's run some tests to see what the result looks like. I've created some content with errors for illustration purposes:
I can easily see what I need to fix because each test points out exactly which document type and which property is wrong. After I fix the content issues, I can run the test again:
And now my test succeeds! 🎉
Final thoughts
It's a great convenience to be able to automatically validate Umbraco content. It gives me and my coworkers a little guidance while we build our products and it provides a quality baseline for any CMS that my team delivers.
Some of you might think that an automated test is not an appropriate place for a check like this. A 'health check' in Umbraco might be a more suitable to validate the state of the content. Fortunately, Umbraco makes it very easy to implement our own health checks and this code is easily adapted to work as a health check. I'll leave that as an exercise to the reader.
That being said, there is a limit to these content checks and it doesn't take away the need for manual quality assurance for the backoffice. It does take care of the more mundane rules and gives us more room to focus on other important checks in the backoffice.
I also feel that this code could still use some improvement. I want to invite my coworkers to create their own content checks as they see fit, but I think the code is a little bit too complex for junior developers to understand. Do you have any ideas or suggestions perhaps? Please let me know with a comment underneath this article!
That's all I have to share for now, I'll see you in my next blog! 😊
Top comments (1)
Thank you very much for your information; it is just what I was looking for. May I ask what software you use for your fantastic and quick website? I too intend to build a straightforward website for my company, however I require advice on a name and hosting. Asphostportal is said to have an excellent reputation for it. Are there any other options available, or can you recommend them?