In the previous lesson, we created a new BlobStorageReadRepository
and exposed various typed instances of it through our RepositoryFactory
. Today, we are going to update our FunctionServiceHelper
to switch between target repositories based on a query string parameter. This will allow us to easily test our game services using different back end Azure storage solutions. We would not likely not surface the repository type in production services. But we are using it here to illustrate how to integrate with different storage types.
Then, we will update all six of our services to use the new FunctionServiceHelper
changes.
Finally, we will update our tests to be able to test our services targeting different backends. We will redesign our tests to provide better sharing of repetitive code and use the xUnit [Theory]
attribute to provide test variations for different repository types.
FunctionServiceHelper Changes
To support switching between multiple repositories, we have to refactor FunctionServiceHelper
a bit. These changes are not a very drastic departures from the initial implementation. At a high-level, we will:
- Remove its constructor because the
IReadableRepository
will no longer be specified for the class instance. - Create helper methods to retrieve the repository and other data from the
HttpRequest.Query
collection. - Update each get method to use the helper methods to retrieve the expected data from the query strings and call the repository with that data.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Repositories;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services.Services
{
public class FunctionServiceHelper<T, TId>
where T : NamedElement<TId>
where TId : struct
{
private struct GetEntitiesSettings
{
internal int? offset;
internal int? limit;
internal IEnumerable<NameValuePair<string>> filters;
internal IEnumerable<NameValuePair<string>> sorts;
}
private const string _repoTypeDefault = "resource";
public async Task<IActionResult> GetEntities(
HttpRequest httpRequest,
ILogger log,
[CallerMemberName] string callingMethod = "GetEntities")
{
try
{
log.LogInformation($"{callingMethod} method called.");
_ = httpRequest ?? throw new ArgumentNullException(nameof(httpRequest));
var settings = GetEntitiesSettingsFromQueryString(httpRequest);
var repo = FindRepository(httpRequest);
var items = await repo.GetEntities(
settings.offset, settings.limit, settings.filters, settings.sorts)
.ConfigureAwait(false);
log.LogInformation($"{callingMethod} method completed successfully.");
return new OkObjectResult(items);
}
catch (Exception ex)
{
log.LogError(ex, $"Error processing {callingMethod} method.");
throw;
}
}
public async Task<IActionResult> GetEntityCount(
HttpRequest httpRequest,
ILogger log,
[CallerMemberName] string callingMethod = "GetEntityCount")
{
try
{
log.LogInformation($"{callingMethod} method called.");
_ = httpRequest ?? throw new ArgumentNullException(nameof(httpRequest));
var filters = GetFiltersFromQueryString(httpRequest);
var repo = FindRepository(httpRequest);
var count = await repo.GetEntityCount(filters).ConfigureAwait(false);
log.LogInformation($"{callingMethod} method completed successfully.");
return new OkObjectResult(count);
}
catch (Exception ex)
{
log.LogError(ex, $"Error processing {callingMethod} method.");
throw;
}
}
public async Task<IActionResult> GetEntityById(
HttpRequest httpRequest,
TId id,
ILogger log,
[CallerMemberName] string callingMethod = "GetEntityById")
{
try
{
log.LogInformation($"{callingMethod} method called.");
_ = httpRequest ?? throw new ArgumentNullException(nameof(httpRequest));
var repo = FindRepository(httpRequest);
var show = await repo.GetEntityById(id).ConfigureAwait(false);
log.LogInformation($"{callingMethod} method completed successfully.");
return new OkObjectResult(show);
}
catch (EntityNotFoundException ex)
{
log.LogInformation(ex, $"{callingMethod} method called with missing id - {id}.");
return new NotFoundResult();
}
catch (Exception ex)
{
log.LogError(ex, $"Error processing {callingMethod} method.");
throw;
}
}
private IReadableRepository<T, TId> FindRepository(HttpRequest httpRequest)
{
string repoType = GetRepoSourceTypeFromQueryString(httpRequest);
return RepositoryFactory.CreateRepository<T, TId>(repoType);
}
private string GetRepoSourceTypeFromQueryString(HttpRequest httpRequest) =>
httpRequest.Query?.GetValue("repo") ?? _repoTypeDefault;
private IEnumerable<NameValuePair<string>> GetFiltersFromQueryString(HttpRequest httpRequest) =>
httpRequest.Query?.GetValues("filters");
private GetEntitiesSettings GetEntitiesSettingsFromQueryString(HttpRequest httpRequest)
{
var settings = new GetEntitiesSettings();
if (httpRequest.Query != null)
{
settings.offset = httpRequest.Query.GetValue<int>("offset");
settings.limit = httpRequest.Query.GetValue<int>("limit");
settings.filters = httpRequest.Query.GetValues("filters");
settings.sorts = httpRequest.Query.GetValues("sorts");
}
return settings;
}
}
}
- Notice that the class constructor and
IReadableRepository
instance member were deleted. - We defined the
_repoTypeDefault
constant (line #25) that holds the default repository type (“resource”). If there is no repository entry in the query string, then we default to using the resource repositories. - Let’s go down to the private helper methods that get various information from query strings (lines #106-130). We noticed that there was a lot of repetitive code in our methods and even more when we had to retrieve repo from the query string, so these helper methods simplify that access and make the main methods easier to read.
- The
FindRepository
method (lines #106-110) calls a method to get the right repository type string and then calls theRepositoryFactory.CreateRepository
to get the correct instance and type of ourIReadableRepository
. - The
GetRepoSourceTypeFromQueryString
method (lines #112-113) looks for a repo entry in the query string collection. If that entry is found, it returns its value. Otherwise it uses_repoTypeDefault
when none is specified. - The
GetFiltersFromQueryString
method (lines #115-116) retrieves filters entry from the query string. If none is found, it could return null or empty list. - The
GetEntitiesSettingsFromQueryString
method (lines #118-130) retrieves all of the entry data for ourGetEntities
method call. It uses theGetEntitiesSettings
struct (defined in lines #17-23) to return all of the data as one result. Any of the settings could be missing from the query string (because they are optional), so they may retrieve data or nulls. The repository code already knows how to deal with that (because it had to do so already in our logic).
- The
- Then in the
GetEntities
method (lines #37-41), we replace all of the query string retrieval code with a call toGetEntitiesSettingsFromQueryString
, we get the current repository by callingFindRepository
, and call theGetEntities
method on the retrieved repository. Finally, we changed theGetEntities
call to pass each of theGetEntitiesSettings
members as parameters. - In the
GetEntityCount
method, we follow a similar pattern. We replace filter query string code with a call toGetFiltersFromQueryString
, we get the current repository by callingFindRepository
, and call theGetEntityCount
method on the retrieved repository. - Finally, the
GetEntityById
requires a signature change because we need to pass in theHttpRequest
object for this service call. We didn’t need it before because we never made use of the request. But now we use it to get the current repository by callingFindRepository
. Then we callGetEntityById
on that retrieved repository.
These are only minor logic changes to our FunctionServiceHelper
to enable multiple repository support, because all of our repositories implement the same IReadableRepository
interface. Defining good abstractions like this can make our code much easier to maintain and extend.
The other changes were mainly cosmetic to make the code easier to read and understand. But that is also very important in long-term maintainability of our code. By moving the repetitive logic into helper methods we could remove that complexity from our entry point methods. By providing descriptive names to those helper methods, it makes the calling methods easier to understand. And the flow and intent of those methods are now much clearer.
ItemTemplateService Changes
Next, we need to update our service classes to use the new version of the FunctionServiceHelper
class. We will take a look at the ItemTemplateService
changes.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Services;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services
{
public static class ItemTemplateService
{
private const string _baseRoute = "item";
private static readonly FunctionServiceHelper<ItemTemplate, int> _serviceHelper =
new FunctionServiceHelper<ItemTemplate, int>();
[FunctionName("GetItemTemplates")]
public static async Task<IActionResult> GetItemTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute)] HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntities(req, log).ConfigureAwait(false);
}
[FunctionName("GetItemTemplateCount")]
public static async Task<IActionResult> GetItemTemplateCount(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "-count")]
HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntityCount(req, log).ConfigureAwait(false);
}
[FunctionName("GetItemTemplateById")]
public static async Task<IActionResult> GetItemTemplateById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "/{id}")]
HttpRequest req,
ILogger log,
int id)
{
return await _serviceHelper.GetEntityById(req, id, log).ConfigureAwait(false);
}
}
}
- Delete the
_repo
member variable and the call to create a resource repository, because we no longer need to pass it into theFunctionServiceHelper
. - Change the creation of
FunctionServiceHelper
to use empty constructor (line #16). - Finally, update the
GetItemTemplateById
method to pass theHttpRequest
object to theFunctionServiceHelper.GetEntityById
method (line #42).
We kept the required changes to the service implementation to a minimum, which is great for adopting this change.
Changes to Remaining Services
Now, we need to repeat the same changes to the rest of our service classes. We will list the changes here without additional description.
1. LocationTemplateService
changes:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using SimpleRPG.Game.Services.DTO;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services.Services
{
public static class LocationTemplateService
{
private const string _baseRoute = "location";
private static readonly FunctionServiceHelper<LocationTemplate, int> _serviceHelper =
new FunctionServiceHelper<LocationTemplate, int>();
[FunctionName("GetLocationTemplates")]
public static async Task<IActionResult> GetLocationTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute)] HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntities(req, log).ConfigureAwait(false);
}
[FunctionName("GetLocationTemplateCount")]
public static async Task<IActionResult> GetLocationTemplateCount(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "-count")]
HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntityCount(req, log).ConfigureAwait(false);
}
[FunctionName("GetLocationTemplateById")]
public static async Task<IActionResult> GetLocationTemplateById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "/{id}")]
HttpRequest req,
ILogger log,
int id)
{
return await _serviceHelper.GetEntityById(req, id, log).ConfigureAwait(false);
}
}
}
2. MonsterTemplateService
changes:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Services;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services
{
public static class MonsterTemplateService
{
private const string _baseRoute = "monster";
private static readonly FunctionServiceHelper<MonsterTemplate, int> _serviceHelper =
new FunctionServiceHelper<MonsterTemplate, int>();
[FunctionName("GetMonsterTemplates")]
public static async Task<IActionResult> GetMonsterTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute)] HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntities(req, log).ConfigureAwait(false);
}
[FunctionName("GetMonsterTemplateCount")]
public static async Task<IActionResult> GetMonsterTemplateCount(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "-count")]
HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntityCount(req, log).ConfigureAwait(false);
}
[FunctionName("GetMonsterTemplateById")]
public static async Task<IActionResult> GetMonsterTemplateById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "/{id}")]
HttpRequest req,
ILogger log,
int id)
{
return await _serviceHelper.GetEntityById(req, id, log).ConfigureAwait(false);
}
}
}
3. QuestTemplateService
changes:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using SimpleRPG.Game.Services.DTO;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services.Services
{
public static class QuestTemplateService
{
private const string _baseRoute = "quest";
private static readonly FunctionServiceHelper<QuestTemplate, int> _serviceHelper =
new FunctionServiceHelper<QuestTemplate, int>();
[FunctionName("GetQuestTemplates")]
public static async Task<IActionResult> GetQuestTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute)] HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntities(req, log).ConfigureAwait(false);
}
[FunctionName("GetQuestTemplateCount")]
public static async Task<IActionResult> GetQuestTemplateCount(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "-count")]
HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntityCount(req, log).ConfigureAwait(false);
}
[FunctionName("GetQuestTemplateById")]
public static async Task<IActionResult> GetQuestTemplateById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "/{id}")]
HttpRequest req,
ILogger log,
int id)
{
return await _serviceHelper.GetEntityById(req, id, log).ConfigureAwait(false);
}
}
}
4. RecipeTemplateService
changes:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using SimpleRPG.Game.Services.DTO;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services.Services
{
public static class RecipeTemplateService
{
private const string _baseRoute = "recipe";
private static readonly FunctionServiceHelper<RecipeTemplate, int> _serviceHelper =
new FunctionServiceHelper<RecipeTemplate, int>();
[FunctionName("GetRecipeTemplates")]
public static async Task<IActionResult> GetRecipeTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute)] HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntities(req, log).ConfigureAwait(false);
}
[FunctionName("GetRecipeTemplateCount")]
public static async Task<IActionResult> GetRecipeTemplateCount(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "-count")]
HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntityCount(req, log).ConfigureAwait(false);
}
[FunctionName("GetRecipeTemplateById")]
public static async Task<IActionResult> GetRecipeTemplateById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "/{id}")]
HttpRequest req,
ILogger log,
int id)
{
return await _serviceHelper.GetEntityById(req, id, log).ConfigureAwait(false);
}
}
}
5. TraderTemplateService
changes:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using SimpleRPG.Game.Services.DTO;
using System.Threading.Tasks;
namespace SimpleRPG.Game.Services.Services
{
public static class TraderTemplateService
{
private const string _baseRoute = "trader";
private static readonly FunctionServiceHelper<TraderTemplate, int> _serviceHelper =
new FunctionServiceHelper<TraderTemplate, int>();
[FunctionName("GetTraderTemplates")]
public static async Task<IActionResult> GetTraderTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute)] HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntities(req, log).ConfigureAwait(false);
}
[FunctionName("GetTraderTemplateCount")]
public static async Task<IActionResult> GetTraderTemplateCount(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "-count")]
HttpRequest req,
ILogger log)
{
return await _serviceHelper.GetEntityCount(req, log).ConfigureAwait(false);
}
[FunctionName("GetTraderTemplateById")]
public static async Task<IActionResult> GetTraderTemplateById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = _baseRoute + "/{id}")]
HttpRequest req,
ILogger log,
int id)
{
return await _serviceHelper.GetEntityById(req, id, log).ConfigureAwait(false);
}
}
}
These are all of the code changes needed to build the SimpleRPG.Game.Services project again.
Redesigning Service Tests
With our source code refactored, we need to update our tests to build and run again. Also, we need to add tests to ensure that the services work with the new BlobStorageReadRepository
.
If we review our service tests, there is a lot of duplicate code. And we’re going to add more tests for the services to use the blob storage repository, so this is a good opportunity to refactor our test code to make it easier to use and reduce duplicate code. Developers spend lots of time refactoring our source code like this, but we often ignore our test code. But we should apply the same design principles and rigor to our test classes.
ServiceTestHelper
First, we have a lot of duplicate code to create mock requests object we our test query strings. So, we’re going to pull that code out into methods in a static helper class. Create the ServiceTestHelper
class in the SimpleRPG.Game.Services.Tests project and Helpers folder.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.Extensions.Primitives;
using Moq;
using System.Collections.Generic;
namespace SimpleRPG.Game.Services.Tests.Helpers
{
internal static class ServiceTestHelper
{
public static HttpRequest CreateRepoQueryStringMock(string repoType)
{
var values = new Dictionary<string, StringValues>
{
{ "repo", new StringValues(repoType) },
};
var query = new QueryCollection(values);
var mockRequest = new Mock<HttpRequest>();
mockRequest.SetupGet(p => p.Query).Returns(query);
return mockRequest.Object;
}
public static HttpRequest CreatePaginationQueryStringMock(string repoType, int offset, int limit)
{
var values = new Dictionary<string, StringValues>
{
{ "offset", new StringValues(offset.ToString()) },
{ "limit", new StringValues(limit.ToString()) },
{ "repo", new StringValues(repoType) },
};
var query = new QueryCollection(values);
var mockRequest = new Mock<HttpRequest>();
mockRequest.SetupGet(p => p.Query).Returns(query);
return mockRequest.Object;
}
public static HttpRequest CreateFiltersQueryStringMock(string repoType, string filters)
{
var values = new Dictionary<string, StringValues>
{
{ "filters", new StringValues(filters) },
{ "repo", new StringValues(repoType) },
};
var query = new QueryCollection(values);
var mockRequest = new Mock<HttpRequest>();
mockRequest.SetupGet(p => p.Query).Returns(query);
return mockRequest.Object;
}
public static HttpRequest CreateSortsQueryStringMock(string repoType, string sorts)
{
var values = new Dictionary<string, StringValues>
{
{ "sorts", new StringValues(sorts) },
{ "repo", new StringValues(repoType) },
};
var query = new QueryCollection(values);
var mockRequest = new Mock<HttpRequest>();
mockRequest.SetupGet(p => p.Query).Returns(query);
return mockRequest.Object;
}
}
}
Each method creates a mock object with the appropriate query string parameters for each test scenario: just the repository type, filters query strings, sorting query string, and pagination query strings.
ServiceTestBase
Then, we define the ServiceTestBase
class to hold all of the test method implementations for our service test cases. We generalize these methods to pass expected results as parameters. And, our service test classes will derive from this base class.
Create the ServiceTestBase
class in the SimpleRPG.Game.Services.Tests in the Services folder.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Tests.Helpers;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
namespace SimpleRPG.Game.Services.Tests.Services
{
public class ServiceTestBase<T, TId>
where T : NamedElement<TId>
where TId : struct
{
private readonly HttpRequest _request = new Mock<HttpRequest>().Object;
private readonly ILogger _logger = new Mock<ILogger>().Object;
protected async Task VerifyGetEntities(
string repoType,
Func<HttpRequest, ILogger, Task<IActionResult>> functionToTest,
int expectedCount,
string expectedName)
{
// arrange
var req = ServiceTestHelper.CreateRepoQueryStringMock(repoType);
// act
var result = await functionToTest(req, _logger).ConfigureAwait(false);
// assert
Assert.NotNull(result);
Assert.IsType<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var templateList = okRes.Value as IList<T>;
Assert.Equal(expectedCount, templateList.Count);
Assert.Contains(templateList, p => p.Name == expectedName);
}
protected async Task VerifyGetEntities_WithNullRequest(
Func<HttpRequest, ILogger, Task<IActionResult>> functionToTest)
{
// arrange
// act/assert
await Assert.ThrowsAsync<ArgumentNullException>(
async () => _ = await functionToTest(null, _logger).ConfigureAwait(false));
}
protected async Task VerifyGetEntities_WithNullLogger(
Func<HttpRequest, ILogger, Task<IActionResult>> functionToTest)
{
// arrange
// act/assert
await Assert.ThrowsAsync<ArgumentNullException>(
async () => _ = await functionToTest(_request, null).ConfigureAwait(false));
}
protected async Task VerifyGetEntities_WithOffsetLimit(
string repoType,
Func<HttpRequest, ILogger, Task<IActionResult>> functionToTest,
int offset,
int limit,
int expectedCount,
string expectedName)
{
// setup
var req = ServiceTestHelper.CreatePaginationQueryStringMock(repoType, offset, limit);
// test
var result = await functionToTest(req, _logger).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var resultList = okRes.Value as IList<T>;
Assert.Equal(expectedCount, resultList.Count);
Assert.Contains(resultList, p => p.Name == expectedName);
}
protected async Task VerifyGetEntityCount(
string repoType,
Func<HttpRequest, ILogger, Task<IActionResult>> functionToTest,
int expectedCount)
{
// setup
var req = ServiceTestHelper.CreateRepoQueryStringMock(repoType);
// test
var result = await functionToTest(req, _logger).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var count = (int)okRes.Value;
Assert.Equal(expectedCount, count);
}
protected async Task VerifyGetEntityCount_WithNullRequest(
Func<HttpRequest, ILogger, Task<IActionResult>> functionToTest)
{
// arrange
// act/assert
await Assert.ThrowsAsync<ArgumentNullException>(
async () => _ = await functionToTest(null, _logger).ConfigureAwait(false));
}
protected async Task VerifyGetEntityCount_WithNullLogger(
Func<HttpRequest, ILogger, Task<IActionResult>> functionToTest)
{
// arrange
// act/assert
await Assert.ThrowsAsync<ArgumentNullException>(
async () => _ = await functionToTest(_request, null).ConfigureAwait(false));
}
protected async Task VerifyGetEntityById(
string repoType,
Func<HttpRequest, ILogger, int, Task<IActionResult>> functionToTest,
int expectedId,
string expectedName)
{
// setup
var req = ServiceTestHelper.CreateRepoQueryStringMock(repoType);
// test
var result = await functionToTest(req, _logger, expectedId).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var actual = okRes.Value as NamedElement<int>;
Assert.Equal(expectedId, actual.Id);
Assert.Equal(expectedName, actual.Name);
}
protected async Task VerifyGetEntityById_WithInvalidId(
string repoType,
Func<HttpRequest, ILogger, int, Task<IActionResult>> functionToTest)
{
// setup
var req = ServiceTestHelper.CreateRepoQueryStringMock(repoType);
// test
var result = await functionToTest(_request, _logger, 42).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<NotFoundResult>(result);
}
protected async Task VerifyGetEntityById_WithNullRequest(
Func<HttpRequest, ILogger, int, Task<IActionResult>> functionToTest)
{
// arrange
// act/assert
await Assert.ThrowsAsync<ArgumentNullException>(
async () => _ = await functionToTest(null, _logger, 1000).ConfigureAwait(false));
}
protected async Task VerifyGetEntityById_WithNullLogger(
Func<HttpRequest, ILogger, int, Task<IActionResult>> functionToTest)
{
// arrange
// act/assert
await Assert.ThrowsAsync<ArgumentNullException>(
async () => _ = await functionToTest(_request, null, 1000).ConfigureAwait(false));
}
}
}
- We pulled these tests from our
ItemTemplateServiceTests
class to ensure we have methods that cover all of the test scenarios. - We will focus on the
VerifyGetEntities
method (lines #21-41). This method takes the following parameters:- a string representing the repository type.
- the service function that will be executed during the test.
- the expected count of the results.
- the expected name of an item in the results.
- Line #28 uses the
repoType
to create our mockHttpRequest
. - Line #31 calls the service method to call.
- Then we verify the expected results including the list count and an item in that list.
The remaining methods follow a similar pattern. You can review all of the methods to get a better understanding of the class.
Updated ItemTemplateServiceTests
Next, we need to update our service tests to derive from ServiceTestBase
. We are only going to look at the changes to ItemTemplateServiceTests
. The other classes follow the same pattern.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SimpleRPG.Game.Services.DTO;
using SimpleRPG.Game.Services.Tests.Helpers;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
namespace SimpleRPG.Game.Services.Tests.Services
{
public class ItemTemplateServiceTests : ServiceTestBase<ItemTemplate, int>
{
private readonly Func<HttpRequest, ILogger, Task<IActionResult>> funcGetItemTemplates =
(r, l) => ItemTemplateService.GetItemTemplates(r, l);
private readonly Func<HttpRequest, ILogger, Task<IActionResult>> funcGetItemTemplateCount =
(r, l) => ItemTemplateService.GetItemTemplateCount(r, l);
private readonly Func<HttpRequest, ILogger, int, Task<IActionResult>> funcGetItemTemplateById =
(r, l, id) => ItemTemplateService.GetItemTemplateById(r, l, id);
private readonly HttpRequest _request = new Mock<HttpRequest>().Object;
private readonly ILogger _logger = new Mock<ILogger>().Object;
[Theory]
[InlineData("resource", "Pointy stick")]
[InlineData("blob", "Pointy stick - blob")]
public async Task GetItemTemplates(string repoType, string expectedItemName) =>
await VerifyGetEntities(repoType, funcGetItemTemplates, 15, expectedItemName);
[Fact]
public async Task GetItemTemplates_WithNullRequest() =>
await VerifyGetEntities_WithNullRequest(funcGetItemTemplates);
[Fact]
public async Task GetItemTemplates_WithNullLogger() =>
await VerifyGetEntities_WithNullLogger(funcGetItemTemplates);
[Theory]
[InlineData("resource", "Pointy stick")]
[InlineData("blob", "Pointy stick - blob")]
public async Task GetItemTemplates_WithOffsetLimit(string repoType, string expectedItemName) =>
await VerifyGetEntities_WithOffsetLimit(
repoType, funcGetItemTemplates, 2, 2, 2, expectedItemName);
[Theory]
[InlineData("resource")]
[InlineData("blob")]
public async Task GetItemTemplateCount_WithFilters(string repoType)
{
// setup
var req = ServiceTestHelper.CreateFiltersQueryStringMock(repoType, "category:0");
// test
var result = await ItemTemplateService.GetItemTemplateCount(req, _logger).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var res = (int)okRes.Value;
Assert.Equal(9, res);
}
[Theory]
[InlineData("resource")]
[InlineData("blob")]
public async Task GetItemTemplates_WithSorting(string repoType)
{
// setup
var req = ServiceTestHelper.CreateSortsQueryStringMock(repoType, "category:asc|price:asc");
// test
var result = await ItemTemplateService.GetItemTemplates(req, _logger).ConfigureAwait(false);
// validate
Assert.NotNull(result);
Assert.IsAssignableFrom<OkObjectResult>(result);
var okRes = (OkObjectResult)result;
Assert.Equal(200, okRes.StatusCode);
var templateList = okRes.Value as IList<ItemTemplate>;
Assert.Equal(15, templateList.Count);
}
[Fact]
public async Task GetItemTemplateCount_WithNullRequest() =>
await VerifyGetEntityCount_WithNullRequest(funcGetItemTemplateCount);
[Fact]
public async Task GetItemTemplateCount_WithNullLogger() =>
await VerifyGetEntityCount_WithNullLogger(funcGetItemTemplateCount);
[Theory]
[InlineData("resource", "Pointy stick")]
[InlineData("blob", "Pointy stick - blob")]
public async Task GetItemTemplateById(string repoType, string expectedItemName) =>
await VerifyGetEntityById(repoType, funcGetItemTemplateById, 1001, expectedItemName);
[Theory]
[InlineData("resource")]
[InlineData("blob")]
public async Task GetItemTemplateById_WithInvalidId(string repoType) =>
await VerifyGetEntityById_WithInvalidId(repoType, funcGetItemTemplateById);
[Fact]
public async Task GetItemTemplateById_WithNullRequest() =>
await VerifyGetEntityById_WithNullRequest(funcGetItemTemplateById);
[Fact]
public async Task GetItemTemplateById_WithNullLogger() =>
await VerifyGetEntityById_WithNullLogger(funcGetItemTemplateById);
}
}
- First, we derive
ItemTemplateServiceTests
fromServiceTestBase
and typed toItemTemplate
(line #14). - Then, we define the member variables that are delegates to our
ItemTemplateService
methods (lines #16-21). These are used in various tests, so it was easier to define them in a single place. - We use the
[Theory]
attribute to define multiple variations to this test method (line #26). - We use multiple
[InlineData]
attributes to specify the repository type and expected item name for each repository type. - The method signature is updated to match the parameters in the
InlineData
(line #29). The xUnit runtime calls this test method for each[InlineData]
attribute with its specified parameters. So this allows us to easily run all of the tests against the resource and blob repository types, without having to create separate tests for each. - Then, we call the
VerifyGetEntities
method (line #30) in the base class passing in the parameters to validate the expected behavior.
The remaining methods in this class do the same thing: define the repository types, and call the corresponding base class method. We have this test class to handle the typing, select the right service method to call, and to define all of the test attributes. We must make similar changes to the other service test classes.
Finally, we can build the test project and run all of the tests. If we run them, we should now see test results for all services using both repository types. And they should all pass.
Running Service Queries
With our service and tests building and passing successfully, we can run our service locally and verify the results with some service URLs. Press F5 to start debugging our Azure Functions services. Here are a few tests to verify our blob and resource repositories.
1. Call ItemTemplateService.GetItemTemplateById
with no query string (localhost:7071/api/item/1001). If the repository type is unspecified, it defaults to resource.

2. Now call the service specifying the blob repository type (localhost:7071/api/item/1001?repo=blob). Notice that the item name has “blob” in its name, so that we can distinguish its source.

3. Call GetTraderTemplates
with the resource repository type (localhost:7071/api/trader?repo=resource) to retrieve all of the traders.

4. Call GetTraderTemplates with blob repository type to get the same list (localhost:7071/api/trader?repo=blob).

In conclusion, we have updated our services to support using different repository backends. We determine the repository to use from the service URL query string. In real world applications, we would likely pick the appropriate storage type and just use that repository for our services. But this code does show how we could support different repository types per service. Sometimes it may make sense to use a different storage mechanism appropriate to that service’s functionality.
With our repository switching in place, we will be able to add repositories easily now without having to change our web services again. In the next lesson, we are going to investigate Azure Table storage to provide our game data.
One thought on “Lesson 6.4: Refactor Services to Consume Multiple Repositories”