Setting up the Test Project and Creating First Unit Test in Kentico Xperience 13(xUnit, AutoFixture, Kentico Fake)
Welcome to our first test!
In this article, we will go through the following:
- Setting up the XperienceAdapter test project
- Creating our first xUnit Test Class
- How to run and debug Unit Tests
- Using AutoFixture and Kentico Fake to setup our tests
We will be creating a unit test for the class XperienceAdapter.Extensions.CultureExtensions.
This is a very simple class and the main purpose is to convert a System.Globalization.CultureInfo into a XperienceAdapter.Models.SiteCulture via an extension method.
Here's a snippet of CultureExtensions.cs which we are going to create a test for:
public static class CultureExtensions
{
public static SiteCulture? ToSiteCulture(this CultureInfo cultureInfo)
{
var siteName = SiteContext.CurrentSiteName;
if (cultureInfo != null && CultureSiteInfoProvider.IsCultureOnSite(cultureInfo.Name, siteName))
{
var culture = CultureSiteInfoProvider
.GetSiteCultures(siteName)
.FirstOrDefault(culture => culture.CultureCode.Equals(cultureInfo.Name, StringComparison.InvariantCulture));
if (culture != null)
{
return SiteCultureRepository.MapDtoProperties(culture, siteName);
}
}
return null;
}
}
Set Target Framework to .NET 6.0
But before writing our first tests, we need to set some things up. First, since .NET 6 is already out, we will upgrade all the MedioClinic project targets from .NET Core 3.1 to .NET 6. It is straightforward, just right-click on the project and change the target framework from .NET Core 3.1 to .NET 6.0. In the example below, just right click on the Business project and click on Properties and you should see the screen below:
In the example below, just right click on the Business project and click on Properties and you should see the screen below:
Create the Test Project
- Right click on the solution and Add a new project.
- Choose the template xUnit Test Project
- Name the project XperienceAdapter.Tests. By convention, tests projects are named after the project to be tested and just add .Tests. Since we are testing XperienceAdapter, we named this project as XperienceAdapter.Tests. When we test Identity, we should be creating a test project named Identity.Tests.
- Choose .NET 6.0 as the target framework
- Your new test project is created!
- Delete the class XperienceAdapter.Tests.UnitTest1.cs
- At this stage, we are ready to do our initial commit for this branch and to move to the next steps.
Add Project Dependencies
Let us now add the NuGet packages and other dependencies needed for our test project.- Kentico.Xperience.Libraries.Tests v13.0.61 - choose the same version as the other Kentico.Xperience libraries used. In this case, it is v13.0.61.
- Moq v4.18.2+
- AutoFixture.Xunit2 v4.17.0+
- Autofac.Extras.Moq v6.1.0+
- XperienceAdapter Project Reference
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" PrivateAssets="all" />
</ItemGroup>
Let's commit our code again and move on to the next steps.
Creating Our First Test
Now we are finally ready to create our first test.- Create a folder named Extensions under XperienceAdapter.Tests
- Create a new class named CultureExtensionsTests.cs under the Extensions folder. By convention, we follow the same folder structure as the project we are testing and create the corresponding test files for the code we are testing and just adding Tests. Since we are creating a test for XperienceAdapter.Extensions.CultureExtensionsXperienceAdapter.Extensions.CultureExtensionsXperienceAdapter.Extensions.CultureExtensions, then we should create our test file in XperienceAdapter.Tests.Extensions.CultureExtensionsTests.
will initially look like this:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace XperienceAdapter.Tests.Extensions
{
internal class CultureExtensionsTests
{
}
}
There are a few changes we need to make to utilize the global usings feature of .NET 6.
When we first created our test project, a Usings.cs was created by default and this is where we will put all our global usings. Since all of the usings in our new test class will be likely used by other tests in our project, we will convert them into global usings and move them into Usings.cs.
Usings.cs
global using Xunit;
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Text;
global using System.Threading.Tasks;
.NET 6 also has an alternative way of using namespace declarations where {} are not required. Our code would now look like below:
CultureExtensionsTests.cs
namespace XperienceAdapter.Tests.Extensions;
public class CultureExtensionsTests
{
[Fact]
public void ToSiteCulture_ValidCulture_ReturnsSameSiteCulture()
{
}
}
But why did we name our test ToSiteCulture_ValidCulture_ReturnsSameSiteCulture? We are following the convention for naming test methods from unit testing best practices . The name of the test should consist of three parts:
- The name of the method being tested.
- The scenario under which it's being tested.
- The expected behavior when the scenario is invoked.
Now we are ready to create the AAA (Arrange, Act, Assert) of our test.
CultureExtensionsTests.cs
using XperienceAdapter.Extensions;
namespace XperienceAdapter.Tests.Extensions;
public class CultureExtensionsTests
{
[Fact]
public void ToSiteCulture_ValidCulture_ReturnsSameSiteCulture()
{
// Arrange
var cultureCode = "en-US";
var cultureInfo = new System.Globalization.CultureInfo(cultureCode);
// Act
var siteCulture = cultureInfo.ToSiteCulture();
// Assert
Assert.NotNull(siteCulture);
Assert.Equal(cultureCode, siteCulture.IsoCode);
}
}
First we added a reference to XperienceAdapter.Extensions. And followed with the following:
- Arrange - setup cultureCode to "en-US" and cultureInfo
- Act - called our ToSiteCulture extension method and stored the result to siteCulture
- Assert - checked that siteCulture is not null and then check if cultureCode is equal to siteCulture.IsoCode
1. While in the body of the test code, right click and click on RunTest.
2. In Test Explorer, navigate to your test and right click and click on
3. While in the body of the test code, type Ctrl+R, TMy preferred is the 3rd option as it is the fastest and we will be running tests regularly. After running test test, I ran into this error:
XperienceAdapter.Tests.Extensions.CultureExtensionsTests.ToSiteCulture_ValidCulture_ReturnsSameSiteCulture
Source: CultureExtensionsTests.cs line 7
Duration: 12 ms
Message:
CMS.Core.ServiceResolutionException : Resolution of 'CMS.Base.Internal.IHttpContextRetriever' failed with the following error: IoC container cannot be used for service resolution because it was not initialized yet. This means that the application was not properly pre-initialized or yet initialized. Use 'CMS.Core.Service.InitializeContainer' to perform container initialization.
---- System.InvalidOperationException : IoC container cannot be used for service resolution because it was not initialized yet. This means that the application was not properly pre-initialized or yet initialized. Use 'CMS.Core.Service.InitializeContainer' to perform container initialization.
Stack Trace:
IoCContainer.ResolveOptional[TService]()
Service.ResolveOptional[TService]()
RequestItems.get_CurrentItems()
RequestStockHelper.get_CurrentItems()
AbstractStockHelper`1.GetItem(String key, Boolean caseSensitive)
VirtualContext.GetItem(String key)
SiteContext.get_CurrentSiteName()
CultureExtensions.ToSiteCulture(CultureInfo cultureInfo) line 16
CultureExtensionsTests.ToSiteCulture_ValidCulture_ReturnsSameSiteCulture() line 14
----- Inner Stack Trace -----
IoCContainer.get_ServiceProvider()
IoCContainer.ResolveOptional[TService]()
4. The error message does not tell us much, only that the test encountered an error. Now instead of running the test, let us debug the test. The options are similar to running a test, but instead of running, choose debug instead. In this case, I prefer keyboard shortcuts so I use Ctrl+R, Ctrl+T.
While debugging, the exception was thrown at our ToSiteCulture method. The error message is the same but we now know exactly where the exception was thrown.
Which takes us to this:
Now we are getting somewhere. CurrentSiteName is a static method and in addition, it is part of the Kentico libraries so we cannot modify it. See this article on how to unit test static methods but for now, let us do a work around.
Further review of ToSiteCulture shows that it has a tightly coupled dependency to SiteContext.CurrentSiteName which makes it difficult to test. We can make a slight adjustment to the method to make it a bit easier to test.
CultureExtensions.cs
public static class CultureExtensions
{
public static SiteCulture? ToSiteCulture(this CultureInfo cultureInfo, string? siteName = null)
{
siteName ??= SiteContext.CurrentSiteName;
if (cultureInfo != null && CultureSiteInfoProvider.IsCultureOnSite(cultureInfo.Name, siteName))
{
var culture = CultureSiteInfoProvider
.GetSiteCultures(siteName)
.FirstOrDefault(culture => culture.CultureCode.Equals(cultureInfo.Name, StringComparison.InvariantCulture));
if (culture != null)
{
return SiteCultureRepository.MapDtoProperties(culture, siteName);
}
}
return null;
}
}
We just added a nullable string parameter siteName and if it is null, we set it to SiteContext.CurrentSiteName which is the previous behavior. We can then revise our test as follows:
CultureExtensionsTests.cs
using XperienceAdapter.Extensions;
namespace XperienceAdapter.Tests.Extensions;
public class CultureExtensionsTests
{
[Fact]
public void ToSiteCulture_ValidCulture_ReturnsSameSiteCulture()
{
// Arrange
var fixture = new Fixture();
var siteName = fixture.Create<string>();
var cultureCode = "en-US";
var cultureInfo = new System.Globalization.CultureInfo(cultureCode);
// Act
var siteCulture = cultureInfo.ToSiteCulture(siteName);
// Assert
Assert.NotNull(siteCulture);
Assert.Equal(cultureCode, siteCulture.IsoCode);
}
}
We then created a new instance of Fixture as fixture and also setup the variable siteName using AutoFixture by calling fixture.Create<string>(). We then used siteName when we called ToSiteCulture.
But why did we not use AutoFixture for cultureCode? AutoFixture generates random values but in the case of cultureCode , we need it to be a known and valid value when it is passed to the CultureInfo constructor.
Now let's debug our code. Let us also examine the variables, especially siteName.
Observe that the value of siteName is random. Now let's proceed and check our call to ToSiteCulture. Uh-oh, another exception but this time we got past our previous exception.
CultureExtensionsTests.cs
using CMS.SiteProvider;
using XperienceAdapter.Extensions;
namespace XperienceAdapter.Tests.Extensions;
public class CultureExtensionsTests : UnitTests
{
[Fact]
public void ToSiteCulture_ValidCulture_ReturnsSameSiteCulture()
{
// Arrange
var fixture = new Fixture();
var siteName = fixture.Create<string>();
var cultureCode = "en-US";
var cultureInfo = new System.Globalization.CultureInfo(cultureCode);
// Kentico Fakes
Fake<CultureSiteInfo>();
var cultureSiteInfo = new CultureSiteInfo
{
SiteID = 1,
CultureID = 1
};
Fake<CultureSiteInfo, CultureSiteInfoProvider>().WithData(
cultureSiteInfo
);
// Act
var siteCulture = cultureInfo.ToSiteCulture(siteName);
// Assert
Assert.NotNull(siteCulture);
Assert.Equal(cultureCode, siteCulture.IsoCode);
}
}
If we want to fake data for the Kentico libraries, we need our test class to inherit from CMS.Tests.UnitTests. We added global using CMS.Tests; to Usings.cs and then updated our test class to inherit from UnitTests.Then we fake CultureSiteInfo and CultureSiteInfoProvider to return cultureSiteInfo. Now let's debug and see if this works.We did not even get to our previous error, but got stock on the first fake. This happens because we are using xUnit as NUnit does the pre-initialization. This is where Marnix Van Valen's article is a big help. We revise our code as follows:
CultureExtensionsTests.cs
using CMS.SiteProvider;
using XperienceAdapter.Extensions;
namespace XperienceAdapter.Tests.Extensions;
public class CultureExtensionsTests : UnitTests
{
public CultureExtensionsTests()
{
InitFixtureBase();
InitBase();
UnitTestsSetUp();
PreInitApplication();
}
[Fact]
public void ToSiteCulture_ValidCulture_ReturnsSameSiteCulture()
{
// Arrange
var fixture = new Fixture();
var siteName = fixture.Create<string>();
var cultureCode = "en-US";
var cultureInfo = new System.Globalization.CultureInfo(cultureCode);
// Kentico Fakes
Fake<CultureSiteInfo>();
var cultureSiteInfo = new CultureSiteInfo
{
SiteID = 1,
CultureID = 1
};
Fake<CultureSiteInfo, CultureSiteInfoProvider>().WithData(
cultureSiteInfo
);
// Act
var siteCulture = cultureInfo.ToSiteCulture(siteName);
// Assert
Assert.NotNull(siteCulture);
Assert.Equal(cultureCode, siteCulture.IsoCode);
}
}
We just added a constructor and called the Init methods that were inherited from CMS.Tests.UnitTests. Let's debug and check our progress.We got past the fake exception but we still encountered an exception at the same spot as before during the call to CultureSiteInfoProvider.IsCultureOnSite but instead of the IInfoProvider error, it is now on the Linq Expression. This is where we have to really analyze what dependencies do we need. Based on the name CultureSiteInfoProvider, we can infer that there is a relation between this, CultureInfo and SiteInfo. So let us create fakes for both CultureInfo and SiteInfo. Updated code as follows:
CultureExtensionsTests.cs
using CMS.Localization;
using CMS.SiteProvider;
using XperienceAdapter.Extensions;
namespace XperienceAdapter.Tests.Extensions;
public class CultureExtensionsTests : UnitTests
{
public CultureExtensionsTests()
{
InitFixtureBase();
InitBase();
UnitTestsSetUp();
PreInitApplication();
}
[Fact]
public void ToSiteCulture_ValidCulture_ReturnsSameSiteCulture()
{
// Arrange
var fixture = new Fixture();
var siteName = fixture.Create<string>();
var siteId = 1;
var cultureCode = "en-US";
var cultureInfo = new System.Globalization.CultureInfo(cultureCode);
// **Kentico Fakes**
// Fake CultureInfo
Fake<CultureInfo>();
var kenticoCultureInfo = new CultureInfo()
{
CultureID =1,
CultureCode = cultureInfo.Name,
CultureName = cultureInfo.Name,
CultureShortName = cultureInfo.ThreeLetterISOLanguageName
};
Fake<CultureInfo, CultureInfoProvider>().WithData(
kenticoCultureInfo
);
// Fake SiteInfo
Fake<SiteInfo>();
var siteInfo = new SiteInfo()
{
SiteName = siteName,
SiteID = siteId
};
Fake<SiteInfo, SiteInfoProvider>().WithData(
siteInfo
);
// Fake CultureSiteInfo
Fake<CultureSiteInfo>();
var cultureSiteInfo = new CultureSiteInfo
{
SiteID = siteInfo.SiteID,
CultureID = kenticoCultureInfo.CultureID
};
Fake<CultureSiteInfo, CultureSiteInfoProvider>().WithData(
cultureSiteInfo
);
// Act
var siteCulture = cultureInfo.ToSiteCulture(siteName);
// Assert
Assert.NotNull(siteCulture);
Assert.Equal(cultureCode, siteCulture.IsoCode);
}
}
First, we moved all of the original arrange section to the constructor so that all the setup code can be shared by our tests.For our original test, we modified the arrange section to instantiate cultureCode and cultureInfo using valid values.
We then created a new test named ToSiteCulture_InValidCulture_ReturnsNull and set the arrange section to use a valid culture code, however, it is not registered as a valid culture for our site based on the fakes that we did. And finally, we revised our assert to expect a null value for siteCulture.
Let's run the test and see they both pass!
We passed!!!
Now we can extend our scenario wherein our site supports multiple languages, e.g. en-US (English US) and es-ES (Spanish Spain). See updated code below:
CultureExtensionsTests.cs
using CMS.Localization;
using CMS.SiteProvider;
using XperienceAdapter.Extensions;
namespace XperienceAdapter.Tests.Extensions;
public class CultureExtensionsTests : UnitTests
{
private readonly string _siteName;
public CultureExtensionsTests()
{
InitFixtureBase();
InitBase();
UnitTestsSetUp();
PreInitApplication();
// Arrange (Common Fixture)
var fixture = new Fixture();
_siteName = fixture.Create<string>();
var siteId = 1;
var enUScultureCode = "en-US";
var enUScultureInfo = new System.Globalization.CultureInfo(enUScultureCode);
var esEScultureCode = "es-ES";
var esEScultureInfo = new System.Globalization.CultureInfo(esEScultureCode);
// **Kentico Fakes**
// Fake CultureInfo
Fake<CultureInfo>();
var kenticoEnUsCultureInfo = new CultureInfo()
{
CultureID = 1,
CultureCode = enUScultureInfo.Name,
CultureName = enUScultureInfo.Name,
CultureShortName = enUScultureInfo.ThreeLetterISOLanguageName
};
var kenticoEsEsCultureInfo = new CultureInfo()
{
CultureID = 2,
CultureCode = esEScultureInfo.Name,
CultureName = esEScultureInfo.Name,
CultureShortName = esEScultureInfo.ThreeLetterISOLanguageName
};
Fake<CultureInfo, CultureInfoProvider>().WithData(
new CultureInfo[]
{
kenticoEnUsCultureInfo,
kenticoEsEsCultureInfo
}
);
// Fake SiteInfo
Fake<SiteInfo>();
var siteInfo = new SiteInfo()
{
SiteName = _siteName,
SiteID = siteId
};
Fake<SiteInfo, SiteInfoProvider>().WithData(
siteInfo
);
// Fake CultureSiteInfo
Fake<CultureSiteInfo>();
var enUsCultureSiteInfo = new CultureSiteInfo
{
SiteID = siteInfo.SiteID,
CultureID = kenticoEnUsCultureInfo.CultureID
};
var esEsCultureSiteInfo = new CultureSiteInfo
{
SiteID = siteInfo.SiteID,
CultureID = kenticoEsEsCultureInfo.CultureID
};
Fake<CultureSiteInfo, CultureSiteInfoProvider>().WithData(
new CultureSiteInfo[]
{
enUsCultureSiteInfo,
esEsCultureSiteInfo
}
);
}
[Theory]
[InlineData("en-US")]
[InlineData("es-ES")]
public void ToSiteCulture_ValidCulture_ReturnsSameSiteCulture(string cultureCode)
{
// Arrange
var cultureInfo = new System.Globalization.CultureInfo(cultureCode);
// Act
var siteCulture = cultureInfo.ToSiteCulture(_siteName);
// Assert
Assert.NotNull(siteCulture);
Assert.Equal(cultureCode, siteCulture.IsoCode);
}
[Fact]
public void ToSiteCulture_InValidCulture_ReturnsNull()
{
// Arrange
var cultureCode = "en-ES";
var cultureInfo = new System.Globalization.CultureInfo(cultureCode);
// Act
var siteCulture = cultureInfo.ToSiteCulture(_siteName);
// Assert
Assert.Null(siteCulture);
}
}
We updated our shared arrange to add a new culture es-ES. We created CultureInfo and CultureSiteInfo fakes for en-US and es-ES.We also updated our ToSiteCulture_ValidCulture_ReturnsSameSiteCulture to accept a string cultureCode parameter and replaced the [Fact] with the [Theory] attribute. See more details on the Theory attribute . And in the inline data, we define the cultureCode that will be passed to our test when run.
Now let's run the test again.
Success!!!
Note that even though we created only 2 test methods, we have 3 tests discovered. It is because each InlineData entry in a Theory is treated as a separate test!
Now it's time to commit, check-in our code and submit a merge request !234 to our master branch as we are finally done.
We have previously setup the CI pipeline for our project and it is automatically run when a merge request is submitted to the master branch.
Our Sonar code coverage report also shows that we have full coverage of our extension.
Summary
In this article, we created our first successful unit test.- We upgraded our projects to target .NET 6.0
- Created a new project XperienceAdapter.Tests
- Created our first test class XperienceAdapter.Tests.Extensions.CultureExtensionsTests
- We learned how to use
- xUnit, Fact and Theory
- AutoFixture
- Kentico CMS.Tests library and faking Kentico Info and InfoProviders
- Test Explorer, running and debugging tests
- Naming convention for Test Projects
- Naming convention for Test Classes
- Naming convention for Test Methods
Hope this helps you start in your unit testing journey for your Kentico Xperience project.
Please post all your Questions here
About Author:
Eugene Paden is the CTO of Ray Business Technologies. He is a Kentico Certified Developer, Certified Boomi Professional Developer and Architect. He has over 25 years of technology experience and has been creating, delivering and implementing business transformation solutions deeply aligned with business strategies and objectives.
Author's Kentico DevNet Profile