Lesson 6.4: Refactor Services to Consume Multiple Repositories

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;
        }
    }
}
  1. Notice that the class constructor and IReadableRepository instance member were deleted.
  2. 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.
  3. 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 the RepositoryFactory.CreateRepository to get the correct instance and type of our IReadableRepository.
    • 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 our GetEntities method call. It uses the GetEntitiesSettings 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).
  4. Then in the GetEntities method (lines #37-41), we replace all of the query string retrieval code with a call to GetEntitiesSettingsFromQueryString, we get the current repository by calling FindRepository, and call the GetEntities method on the retrieved repository. Finally, we changed the GetEntities call to pass each of the GetEntitiesSettings members as parameters.
  5. In the GetEntityCount method, we follow a similar pattern. We replace filter query string code with a call to GetFiltersFromQueryString, we get the current repository by calling FindRepository, and call the GetEntityCount method on the retrieved repository.
  6. Finally, the GetEntityById requires a signature change because we need to pass in the HttpRequest 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 calling FindRepository. Then we call GetEntityById 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 the FunctionServiceHelper.
  • Change the creation of FunctionServiceHelper to use empty constructor (line #16).
  • Finally, update the GetItemTemplateById method to pass the HttpRequest object to the FunctionServiceHelper.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 mock HttpRequest.
  • 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 from ServiceTestBase and typed to ItemTemplate (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.

Fig 1 – Get Item with No Query String

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.

Fig 2 – Get Item from Blob Repository

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

Fig 3 – Get TraderList from Resource Repository

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

Fig 4 – Get Trader List from Blob Repository

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.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s