UmbracoSupport got typed content
What's UmbracoSupport?
UmbracoSupport
is a class I've been introducing to my unit tests over the last year or so.
It allows me to have my own hierarchy for tests, as well as re-using all of Umbraco's own
stubbing code. I've written about it in a post called Unit testing Umbraco just got simpler,
and its gut's code is described in details in The basics of unit testing Umbraco.
A quick primer on what's already available
The BaseDatabaseFactoryTest
in Umbraco.Tests
has a method called GetXmlContent
.
It replaces the umbraco.config
file that acts as the cache at runtime.
It makes UmbracoContext.Current.ContentCache
tick in unit tests.
The base tests out of the box has a small flaw though. They can't "popuplate" properties.
All you get is the hierarchy.
Usually I've injected an IPublishedContentCache
to my controllers. When testing them,
I've created a mock instance of the IPublishedContentCache
. However, all my code has to use
the non-context aware overloads. For instance cache.GetById(umbracoContext, false, id)
.
There's also a whole lot of ugly mocking code going on to set up queries and stubbed content.
How to stub properties on stubbed content is described in Slides from CG 2016 and testing IPublishedContent properties.
So what's new?
As mentioned, I've been throwing around all kinds of ugly stubbing code for content and I've also been tampering with Umbraco.Tests
's GetXmlContent()
to use the "built-in" content stubs.
It's all been done before in misc. tests in Umbraco. I finally got my s**t together and refactored all my setup spaghetti into a few small helpers on the UmbracoSupport
class.
Let's go over them in increasing "integrationness".
Pure hierarchy
Your basic hierarchy structure can be set up by simply returning a string from an overload of BaseDatabaseFactoryTest.GetXmlContent
. UmbracoSupport
overloads this method and returns whatever you've set on the UmbracoSupport.ContentCacheXml
property. I recommend using the technique described in Automating creating of source data for tests with this. You can even extend that code to have fixture specific content caches.
In any case, to make this work, you just need to set the XML in the setup method.
Note: I've got some probs with the markdown parsing here, imagine the CDATA parts of the XML is correctly written.
[SetUp]
public void Setup()
{
umbracoSupport = new UmbracoSupport();
umbracoSupport.SetupUmbraco();
// This XML is what the ContentCache will represent
umbracoSupport.ContentCacheXml = @"
<?xml version=""1.0"" encoding=""utf-8""?>
<!DOCTYPE root [
<!ELEMENT contentBase ANY>
<!ELEMENT home ANY>
<!ATTLIST home id ID #REQUIRED>
<!ELEMENT page ANY>
<!ATTLIST page id ID #REQUIRED>
]>
<root id=""-1="""">
<home id=""1103="""" key=""156f1933-e327-4dce-b665-110d62720d03="""" parentID=""-1="""" level=""1="""" creatorID=""0="""" sortOrder=""0="""" createDate=""2017-10-17T20:25:12="""" updateDate=""2017-10-17T20:25:17="""" nodeName=""Home="""" urlName=""home="""" path=""-1,1103="""" isDoc="""" nodeType=""1093="""" creatorName=""Admin="""" writerName=""Admin="""" writerID=""0="""" template=""1064="""" nodeTypeAlias=""home="""">
<title>Welcome!</title>
<excerptCount>4</excerptCount>
<page id=""1122="""" key=""1cb33e0a-400a-4938-9547-b05a35739b8b="""" parentID=""1103="""" level=""2="""" creatorID=""0="""" sortOrder=""0="""" createDate=""2017-10-17T20:25:12="""" updateDate=""2017-10-17T20:25:17="""" nodeName=""Page="" 1="""" urlName=""page1="""" path=""-1,1103,1122="""" isDoc="""" nodeType=""1095="""" creatorName=""Admin="""" writerName=""Admin="""" writerID=""0="""" template=""1060="""" nodeTypeAlias=""page="""">
<title>Welcome!</title>
<excerpt>[CDATA[Lorem ipsum dolor...]]</excerpt>
<body>
[CDATA[<p>Lorem ipsum dolor...</p>]]
</body>
<image>123</image>
</page>
<page id=""1123="""" key=""242928f6-a1cf-4cd3-ac34-f3ddf3526b2e="""" parentID=""1103="""" level=""2="""" creatorID=""0="""" sortOrder=""1="""" createDate=""2017-10-17T20:25:12="""" updateDate=""2017-10-17T20:25:17="""" nodeName=""Page="" 2="""" urlName=""page2="""" path=""-1,1103,1123="""" isDoc="""" nodeType=""1095="""" creatorName=""Admin="""" writerName=""Admin="""" writerID=""0="""" template=""1060="""" nodeTypeAlias=""page="""">
<title>More welcome!</title>
<excerpt>[CDATA[More lorem ipsum dolor...]]</excerpt>
<body>[CDATA[Even more lorem ipsum dolor...]]</body>
<image>234</image>
</page>
</home>
</root>
".Trim();
}
In our tests, we can now query by anything. The returned content has the hierarchy and everything, so we can traverse it with Children()
, Parent()
and whatnot.
The only missing piece is the properties. Here's a test showing that we have everything but the title property of Page 1:
const int Page1Id = 1122;
[Test]
public void Returns_Empty_Documents()
{
var contentCache = umbracoSupport.UmbracoContext.ContentCache;
var page1 = contentCache.GetById(Page1Id);
Assert.That(page1, Is
.Not.Null
.And
.InstanceOf<PublishedContentWithKeyBase>()
.And
.Property("Name").EqualTo("Page 1")
.And
.Matches<IPublishedContent>(c => c["title"] == null)
.And
.Property("Parent")
.Property("Children")
.With.Count.EqualTo(2)
);
}
Don't be discouraged though. This method is excellent for testing URL providers, ContentFinders, Menus, Sitemaps. You name it. I know I've written my fair share of hierarchy traversing code or fancy XPath queries. Unless of course, you need property values.
Instead of pulling your leg about it, here's how we fix that.
Put some meat on the content
The reason the properties are not there isn't because they weren't read. It's because the XmlPublishedContent
that we get out ultimately relies on the PublishedContentType
for it's respective document type. Luckily, all Umbraco's services are already stubbed up for us, so we can give it what it needs.
The gory guts of it is that it needs an IContentType
from the ContentTypeService
. We can easily stub one up with Moq: var contentType = Mock.Of<IContentType>()
. Further, it uses the IContentType.CompositionPropertyTypes
collection to iterate the properties. These PropertyType
instances are actually completely dependency-less, so we can just create some:
Mock.Get(contentType)
.Setup(t => t.CompositionPropertyTypes)
.Returns(new[] {
new PropertyType("Umbraco.TinyMCEv3", DataTypeDatabaseType.Nvarchar, "body")
});
Finally, we set it up on the ContentTypeService
stub:
Mock.Get(umbracoSupport.ServiceContext.ContentTypeService)
.Setup(s => s.GetContentType(alias))
.Returns(contentType);
If only it were so easy. We depend on the BaseWebTest
class from Umbraco.Tests
. It sets up a content type factory that's being used somewhere in the hierarchy. It feeds AutoPublishedContent
instances instead of what we've stubbed up. We need to turn that off. There's a trick here. UmbracoSupport
should now live in an assembly called Umbraco.UnitTests.Adapter
. If you're pre 7.6.4 you need to go with Umbraco.VisualStudio
. This is because the factory we need to reset is internal to Umbraco. By having UmbracoSupport
in an assembly with one of these two names, we're able to do it. (Otherwise, you use reflection.) By no means do this with production code. Just... forget it!
This paragraph should also get it's own blog post. :)
But I digress. Here's the line you need to have the content use the ContentTypeService
to fetch its type:
PublishedContentType.GetPublishedContentTypeCallback = null;
It's tempting to leave setup code like this lying around in all our SetUp
methods or even in our "Arrange" sections. I've sinned too much, so those few lines are now part of UmbracoSupport
and can be used to set up multiple types for your fixture or test.
Here's a test that fetches the same document as before, but can now read properties:
[Test]
public void With_DocumentTypes_Setup_Returns_Full_Blown_Documents()
{
umbracoSupport.SetupContentType("page", new[]
{
new PropertyType("textstring", DataTypeDatabaseType.Nvarchar, "title"),
new PropertyType("textarea", DataTypeDatabaseType.Nvarchar, "excerpt"),
new PropertyType("Umbraco.TinyMCEv3", DataTypeDatabaseType.Nvarchar, "body"),
new PropertyType("media", DataTypeDatabaseType.Integer, "image")
});
var page1 = contentCache.GetById(Page1Id);
Assert.Multiple(() =>
{
Assert.That(page1["title"], Is.EqualTo("Welcome!"));
Assert.That(page1["excerpt"], Is.EqualTo("Lorem ipsum dolor..."));
Assert.That(page1["body"].ToString(), Is.EqualTo("<p>Lorem ipsum dolor...</p>"));
Assert.That(page1["image"], Is.EqualTo(123));
});
}
Notice the .ToString() on the body. It's actually not a string, but some weird dynamic Umbraco thingy. I never saw that type before, but I didn't pursue it in time for this post. I don't want anything to do with it though, so let's storm on to the grand finale.
Let's make them strong already!
We're finally there. The last piece of the puzzle. Strongly typed content!
It's managed by two resolvers: PublishedContentModelFactoryResolver
and PropertyValueConvertersResolver
. I won't go into details about those now, but suffice to say all resolvers have to be initialized before BaseWebTest.Initialize
and its ancestors.
I've added an InitializeResolvers
method to the UmbracoSupport
class where these two are initialized. The PublishedContentModelFactoryResolver
is set to a FakeModelFactoryResolver
that lets you register constructors for document type aliases. The code for this is available in my "Umbraco unit testing samples" repo on github.
To set up property value converters, we also need to do that before registering the resolver. The resolver takes all the converters as constructor arguments. I've added a list of those types as a property on UmbracoSupport
, so we can add IPropertyValueConverter
implementing types before calling UmbracoSupport.SetupUmbraco
:
[SetUp]
public void Setup()
{
umbracoSupport = new UmbracoSupport();
// Converter types need to be added before setup
umbracoSupport.ConverterTypes.Add(typeof(TinyMceValueConverter));
umbracoSupport.SetupUmbraco();
//...
}
To register the typed model, there's just one line you can do in your setup, or even in your tests. Here I've refactored out the setup for the content type from earlier, and I register a model type for the document type alias:
private void SetupContentType()
{
umbracoSupport.SetupContentType("page", new[]
{
new PropertyType("textstring", DataTypeDatabaseType.Nvarchar, "title"),
new PropertyType("textarea", DataTypeDatabaseType.Nvarchar, "excerpt"),
new PropertyType("Umbraco.TinyMCEv3", DataTypeDatabaseType.Nvarchar, "body"),
new PropertyType("media", DataTypeDatabaseType.Integer, "image")
});
}
[Test]
public void With_DocumentTypes_And_Models_Setup_Returns_Fully_Functional_Typed_Content()
{
SetupContentType();
// Register strongly typed models with the ModelFactory
umbracoSupport.ModelFactory.Register("page", c => new Page(c));
var page1 = contentCache.GetById(Page1Id);
Assert.That(page1, Is
.InstanceOf<Page>()
.And.Property("Body")
.Matches<IHtmlString>(s =>
s.ToString() == "<p>Lorem ipsum dolor...</p>"
)
);
}
public class Page : PublishedContentModel
{
public Page(IPublishedContent content) : base((IPublishedContentWithKey)content)
{
}
public string Title => Content.GetPropertyValue<string>("title");
public string Excerpt => Content.GetPropertyValue<string>("excerpt");
public IHtmlString Body => Content.GetPropertyValue<IHtmlString>("body");
public int Image => Content.GetPropertyValue<int>("image");
}
There you go! There's nothing more to it. Well, there is...
The Page
class here is bundled with the test. If we use a common interface both for our runtime model and our test model, we're safe. But we should really use the runtime models. This means you shouldn't use runtime generated models. Go through the instructions for ModelsBuilder to have your models compiled and accessible from the tests.
Conclusion
And although the XML is pretty ugly, you can flush it out into files bundled with your tests. You can also exploit the umbraco.config file and just copy segments from there into your test source files. That way, you spend no time writing the stubs, and the content is cleanly separated from your tests.
That's really all there is to it! It is. Now go test a bit, or a byte, or a string, or even a view.
The new version of UmbracoSupport including the fake model factory is available here.