XUnit Unit Test (Practical Project) – I won’t hit you with you after reading this

1. Introduction

xUnit.net is a free, open source unit testing framework for .NET that enables parallel testing and data-driven testing. The test project needs to reference both xUnit and the project under test to test it. After the test is written, use Test Runner to test the project. Test Runner can read the test code and know the test framework to be used, then execute it and display the results.

2. Support platform

xUnit.net currently supports .Net Framework, .Net Core, .Net Standard, UWP, and Xamarin. You can use xUnit for testing on these platforms.

3. Core idea

The core idea of unit testing: everything is virtual (mock data), when testing a certain class, it is necessary to assume that other classes are normal, and the directory structure of the unit test code and the code under test should be consistent.
The configuration item is virtual, and the values of each attribute of the configuration item need to be reset.
Class instances are virtual, and the results returned by different methods in the class instance must be set in advance.
Http requests are virtual, and the results returned by different http requests must be set in advance.

4. Detailed explanation of the specific usage of XUnit

To write a unit test, you must first have the function/service to be tested. The process of writing a service will not be described in detail here. When writing a unit test, I will take a screenshot of the corresponding key code of the service for everyone to compare. The following officially begins:

1. Prepare the environment

1.1 New project

1.2 Overview of installation dependencies and project structure (FluentAssertions, Moq)

2. Unit test AnalyzerService.cs

Let’s do a simple unit test on AnalyzerService.cs first. For details, please refer to the following steps:

  • Create the corresponding test class AnalyzerServiceUnitTest.cs
  • Declare the test object IAnalyzerService _sut (abbreviation for System Under Test system under test)
  • Create an AnalyzerService instance in the constructor and assign it to the test object. All parameters required to create the instance must use mock data. If any are missing, declare them.
  • After declaring and initializing the mock data with new, you must set the properties/methods before they can be used (echoing the third core idea, this is also the most important step in unit testing)
  • After the test object is created, test methods must be created based on IAnalyzerService (unit tests must include all methods in it, and even more)
  • When a single method business is more complex, it can be split into multiple test methods based on if conditions, so there may be more test methods than the original methods of the interface.
  • If the original method has a return value, you can make an assertion based on whether the return value meets expectations. If the original method has no return value, just don’t report an error. If you need to judge an exception, I also have a way to handle it.
  • Let’s go directly to the code. I will add comments as much as possible to help everyone understand.

namespace LearnUnitTest.Test.Services
{
    public class AnalyzerServiceUnitTest
    {
        private readonly string _token;
        private readonly string _fileMetadataId;
        //Declare the test object
        private readonly IAnalyzerService _sut;
        //Declare the parameters required by new AnalyzerService()
        private readonly Mock<ISauthService> _sauthService;
        private readonly Mock<IFileMetadataService> _fileMetadataService;
        private readonly Mock<IProcessTimeService> _processTimeService;
        private readonly Mock<IErrorRecordService> _errorRecordService;
        private readonly Mock<IOptions<TimeSettings>> _timeSettings;
        private readonly Mock<IOptions<ReferencingServices>> _referencingServices;
        private readonly Mock<IOptions<ErrorRecordSetting>> _errorRecordSetting;
        private readonly Mock<IActivityService> _activityService;
        private readonly Mock<ILogger<AnalyzerService>> _logger;
        private const string ExceptionMsg = "UT_Exception_Message";

        public AnalyzerServiceUnitTest()
        {
            _token = Guid.NewGuid().ToString();
            _fileMetadataId = "UT_FileMetadataId";
            _sauthService = new Mock<ISauthService>();
            _fileMetadataService = new Mock<IFileMetadataService>();
            _processTimeService = new Mock<IProcessTimeService>();
            _errorRecordService = new Mock<IErrorRecordService>();
            _timeSettings = new Mock<IOptions<TimeSettings>>();
            _referencingServices = new Mock<IOptions<ReferencingServices>>();
            _errorRecordSetting = new Mock<IOptions<ErrorRecordSetting>>();
            _activityService = new Mock<IActivityService>();
            _logger = new Mock<ILogger<AnalyzerService>>();
            //After creating an instance of mock data, you must set the properties/methods according to the needs before it can be used.
            InitOptions();
            InitServices();

            //Create an AnalyzerService instance and assign it to the test object
            _sut = new AnalyzerService(_sauthService.Object, _fileMetadataService.Object,
                _processTimeService.Object, _errorRecordService.Object,
                _timeSettings.Object, _referencingServices.Object, _errorRecordSetting.Object,
                _activityService.Object, _logger.Object);
        }

        [Fact]
        public async Task Analyze_ShouldSuccess_WhenHasLatestProcessTime()
        {
            LatestProcessTimeRecord latestProcessTimeRecord = new LatestProcessTimeRecord()
            {
                //Set the return value to be non-null
                LatestProcessTime = DateTime.UtcNow
            };
            //GetLatestProcessTimeAsync() in AnalyzerService will perform different logical processing when returning empty and non-empty, so it is split into two methods
            // Logically cover different situations by rewriting the return value of GetLatestProcessTimeAsync()
            _processTimeService.Setup(x=>x.GetLatestProcessTimeAsync(It.IsAny<string>()))
                .ReturnsAsync(latestProcessTimeRecord);

            var exception = await Record.ExceptionAsync(async () => await _sut.AnalyzeAsync());
            exception.Should().BeNull();
        }

        [Fact]
        public async Task Analyze_ShouldSuccess_WhenNoLatestProcessTime()
        {
            LatestProcessTimeRecord latestProcessTimeRecord = new LatestProcessTimeRecord()
            {
                //Set the return value to empty
                LatestProcessTime = null
            };
            //GetLatestProcessTimeAsync() in AnalyzerService will perform different logical processing when returning empty and non-empty, so it is split into two methods
            // Logically cover different situations by rewriting the return value of GetLatestProcessTimeAsync()
            _processTimeService.Setup(x => x.GetLatestProcessTimeAsync(It.IsAny<string>()))
                .ReturnsAsync(latestProcessTimeRecord);

            Exception exception = await Record.ExceptionAsync(async () => await _sut.AnalyzeAsync());
            exception.Should().BeNull();
        }

        [Fact]
        public async Task Analyze_ShouldRecordErrorLog_WhenThrowException()
        {
            LatestProcessTimeRecord latestProcessTimeRecord = new LatestProcessTimeRecord()
            {
                LatestProcessTime = DateTime.UtcNow
            };
            _processTimeService.Setup(x => x.GetLatestProcessTimeAsync(It.IsAny<string>()))
                .ReturnsAsync(latestProcessTimeRecord);
            _fileMetadataService.Setup(x => x.QueryMetadatasAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
                .Callback(() =>
                {
                    throw new Exception(ExceptionMsg);
                });

            await Record.ExceptionAsync(async () => await _sut.AnalyzeAsync());
            //LogLevel parameter writing method 1
            _logger.Verify(x => x.Log(
                It.Is<LogLevel>(logLevel => logLevel == LogLevel.Error),
                It.IsAny<EventId>(),
                It.IsAny<It.IsAnyType>(),
                It.IsAny<Exception>(),
                It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
                Times.Once);
            //LogLevel parameter writing method 2
            _logger.Verify(x => x.Log(
                LogLevel.Error,
                It.IsAny<EventId>(),
                It.IsAny<It.IsAnyType>(),
                It.IsAny<Exception>(),
                It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
                Times.Once);
        }

        [Fact]
        public async Task NoImplement_ShouldThrowException_Method01()
        {
            Func<Task> result = async () => await _sut.NoImplementAsync(_token);
            //Chained exception judgment writing method 1
            await result.Should().ThrowAsync<NotImplementedException>();
        }

        [Fact]
        public async Task NoImplement_ShouldThrowException_Method02()
        {
            Exception exception = await Record.ExceptionAsync(async () => await _sut.NoImplementAsync(_token));
            //Chained exception judgment writing method 2
            exception.Should().BeOfType<NotImplementedException>();
        }

        [Fact]
        public async Task NoImplement_ShouldThrowException_Method03()
        {
            try
            {
                //try...catch for exception judgment
                await _sut.NoImplementAsync(_token);
                Assert.True(false);
            }
            catch (NotImplementedException ex)
            {
                Assert.True(true);
            }
            catch
            {
                Assert.True(false);
            }
        }

        [Theory]
        [InlineData(NamingFileType.MaxwellSMLSummary, "EVQ", "maxwellsmlsummary-evq")]
        [InlineData(NamingFileType.MaxwellSMLSummary, "evq", "maxwellsmlsummary-evq")]
        [InlineData(NamingFileType.MaxwellSMLSummary, "EVT", "maxwellsmlsummary-evt")]
        [InlineData(NamingFileType.MaxwellSMLSummary, "evt", "maxwellsmlsummary-evt")]
        [InlineData(NamingFileType.MaxwellSMLSummary, "PROD", "maxwellsmlsummary")]
        [InlineData(NamingFileType.MaxwellSMLSummary, "prod", "maxwellsmlsummary")]
        [InlineData(NamingFileType.MaxwellSMLSummary, "", "maxwellsmlsummary")]
        [InlineData(NamingFileType.General, "evQ", "general-evq")]
        [InlineData(NamingFileType.General, "EvT", "general-evt")]
        [InlineData(NamingFileType.General, "PRod", "general")]
        [InlineData(NamingFileType.General, "", "general")]
        public void GetContainerName_ShouldReturnCorrectContainer(NamingFileType fileType, string envionment, string expect)
        {
            string containerName = fileType.GetContainerName(envionment);
            containerName.Should().BeEquivalentTo(expect);
        }


        private void InitOptions()
        {
            TimeSettings timeSettings = new TimeSettings()
            {
                DefaultUploadTime = "2022-06-01T00:00:00.000Z"
            };
            //Variables of type IOptions<T> obtain actual parameter values through the Value attribute, so the Value attribute must be overridden here.
            _timeSettings.Setup(x => x.Value).Returns(timeSettings);

            ReferencingServices referencingServices = new ReferencingServices()
            {
                DataPartitionId = "DataPartition-Id",
                FileServiceURL = "https://global.FileService.URL",
                ActivityServiceURL = "https://global.ActivityService.URL",
                ProcessStatusServiceURL = "https://global.ProcessStatusService.URL"
            };
            _referencingServices.Setup(x => x.Value).Returns(referencingServices);

            ErrorRecordSetting errorRecordSetting = new ErrorRecordSetting()
            {
                DefaultMaxRetryCount = 5
            };
            _errorRecordSetting.Setup(x => x.Value).Returns(errorRecordSetting);
        }

        private void InitServices()
        {
            //ISauthService.GetToken() is called in AnalyzerService. If GetToken() is not rewritten, the return value of all places where this method is used is null (the return value is string type)
            _sauthService.Setup(x => x.GetToken()).ReturnsAsync(_token);

            List<FileMetadataGetResponse> fileMetadatas = new List<FileMetadataGetResponse>()
            {
                new FileMetadataGetResponse() { Id = _fileMetadataId }
            };
            //When the method that needs to be overridden has parameters, use It.IsAny<T>() instead according to the parameter type.
            _fileMetadataService.Setup(x => x.QueryMetadatasAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
                .ReturnsAsync(fileMetadatas);

            WellTestingEmission wellTestingEmission = new WellTestingEmission()
            {
                Id = _fileMetadataId,
                CreatedTime = DateTime.UtcNow,
                Emissions = new List<EmissionInfo>()
                {
                    newEmissionInfo()
                    {
                        Name = "CO2EstimatedEmissionForGas",
                        Unit = "T",
                        Value = 0.0f
                    }
                }
            };
            _fileMetadataService.Setup(x => x.GetFileContentByMetadataIdAsync(It.IsAny<string>(), It.IsAny<string>()))
                .ReturnsAsync(wellTestingEmission);

            _errorRecordService.Setup(x => x.InsertProcessErrorAsync(It.IsAny<ErrorBaseRecord>(), It.IsAny<string>(), It.IsAny<string>()));

            _errorRecordService.Setup(x => x.UpdateProcessErrorAsync(It.IsAny<ErrorBaseRecord>(), It.IsAny<string>(), It.IsAny<string>()));

            _activityService.Setup(x => x.InsertOrUpdateActivityAsync(It.IsAny<string>(), It.IsAny<WellTestingEmission>(), It.IsAny<OperationalActivity>(), It.IsAny<string>()));
        }
    }
}

3. Unit test ActivityService.cs

This section adds processing of http requests based on the AnalyzerServiceUnitTest.cs unit test (directly using System.Net.Http.HttpClient to send Post/Get requests). The unit test cannot send real http requests, so we have to process different Set corresponding return values (mock data) for http requests respectively. Please refer to the following steps for details:

  • Create the corresponding test class ActivityServiceUnitTest.cs
  • Declare the test object IActivityService _sut (abbreviation of System Under Test)
  • Create an ActivityService instance in the constructor and assign it to the test object. All parameters required to create the instance must use mock data. If any are missing, declare them.
  • After declaring and initializing the mock data with new, you must set the properties/methods before they can be used (echoing the third core idea, this is also the most important step in unit testing)
  • After the test object is created, test methods must be created based on IActivityService (unit tests must include all methods in it, and even more)
  • When a single method business is more complex, it can be split into multiple test methods based on if conditions, so there may be more test methods than the original methods of the interface.
  • If the original method has a return value, you can make an assertion based on whether the return value meets expectations. If the original method does not return a value, just don’t report an error. If you need to judge an exception, I also have a way to handle it (refer to the previous section)
  • Let’s go directly to the code. I will add comments as much as possible to help everyone understand.

namespace LearnUnitTest.Test.Services
{
    public class ActivityServiceUnitTest
    {
        private readonly string _token;
        private readonly string _fileMetadataId;
        //Objects used multiple times must be defined as global variables
        private readonly WellTestingEmission _wellTestingEmission;
        private readonly OperationalActivity _operationalActivity;
        //Declare the parameters required for new ActivityService()
        private readonly IActivityService _sut;
        private readonly Mock<ISauthService> _sauthService;
        private readonly Mock<IOptions<ReferencingServices>> _referencingServices;
        private readonly Mock<ILogger<ActivityService>> _logger;
        private readonly Mock<IHttpClientWrapperService> _httpClientWrapperSvc;

        public ActivityServiceUnitTest()
        {
            _token = Guid.NewGuid().ToString();
            _fileMetadataId = "UT_FileMetadataId";
            _sauthService = new Mock<ISauthService>();
            _referencingServices = new Mock<IOptions<ReferencingServices>>();
            _logger = new Mock<ILogger<ActivityService>>();
            _httpClientWrapperSvc = new Mock<IHttpClientWrapperService>();
            _wellTestingEmission = new WellTestingEmission()
            {
                JobId = "UT_JobId",
                JobName = "UT_JobName",
                CountryOfOrigin = "UT_CountryOfOrigin",
                CustomerName = "UT_CustomerName",
                FdpNumber = "UT_FdpNumber"
            };
            _operationalActivity = new OperationalActivity()
            {
                Wells = new[] { new Well() { Wellname = "UT_Wellname01", Wellfield = "UT_Wellfield01" } }
            };
            //After creating an instance of mock data, you must set the properties/methods according to the needs before it can be used.
            InitOptions();
            InitServices();

            _sut = new ActivityService(_sauthService.Object, _httpClientWrapperSvc.Object,
                _referencingServices.Object, _logger.Object);
        }

        [Fact]
        public async Task ExtractActivity_ShouldSuccess()
        {
            var excepted = new ActivityCreateUpdateRequest
            {
                WellInfo = new WellData
                {
                    WellName = _operationalActivity?.Wells[0]?.Wellname,
                    FieldName = _operationalActivity?.Wells[0]?.Wellfield,
                    CountryCode = _wellTestingEmission.CountryOfOrigin,
                    ClientName = _wellTestingEmission.CustomerName
                },
                Execution = new ExecutionData
                {
                    Id = _wellTestingEmission.JobId,
                    Name = _wellTestingEmission.JobName,
                    Time = Convert.ToDateTime(_wellTestingEmission.CreationDate),
                    System = ExecutionSystem.Tallix,
                    Status = ExecutionStatus.Completed
                },
                BusinessContext = new BusinessContextData
                {
                    FDPNumber = _wellTestingEmission.FdpNumber
                },
                Details = new ActivityDetails
                {
                    MetadataIds = new List<string>() { _fileMetadataId }
                }
            };
            RequestBase<ActivityCreateUpdateRequest> result = _sut.ExtractActivity(_fileMetadataId, _wellTestingEmission, _operationalActivity);
            //Compare attribute values one by one to see if they are the same
            excepted.Should().BeEquivalentTo(result.Data);
        }

        [Fact]
        public async Task InsertOrUpdateActivity_ShouldSuccess_WhenInsert()
        {
            ResponseBase<ActivityBatchQueryResponse> activityBatchQueryResponse = new ResponseBase<ActivityBatchQueryResponse>()
            {
                Data = new ActivityBatchQueryResponse()
                {
                    //TotalCount<=0
                    TotalCount = 0
                }
            };
            ResponseBase<ActivityCreateResponse> activityCreateResponse = new ResponseBase<ActivityCreateResponse>()
            {
                Data = new ActivityCreateResponse() { Id= _wellTestingEmission.JobId }
            };
            //When calling multiple PostAsync/GetAsync in one method, set them according to different parameter types.
            _httpClientWrapperSvc.Setup(x => ), It.IsAny<string>()))
                .Returns(Task.FromResult(activityBatchQueryResponse));
            _httpClientWrapperSvc.Setup(x => ), It.IsAny<string>()))
                .Returns(Task.FromResult(activityCreateResponse));
            //When the method does not return a value, there must be no exceptions during the execution of the check.
            var exception = await Record.ExceptionAsync(async () => await _sut.InsertOrUpdateActivityAsync(_fileMetadataId, _wellTestingEmission, _operationalActivity, _token));
            exception.Should().BeNull();
        }

        [Fact]
        public async Task InsertOrUpdateActivity_ShouldSuccess_WhenInsert_Met401()
        {
            int calls = 0;
            ResponseBase<ActivityBatchQueryResponse> activityBatchQueryResponse = new ResponseBase<ActivityBatchQueryResponse>()
            {
                Data = new ActivityBatchQueryResponse()
                {
                    //TotalCount<=0
                    TotalCount = 0
                }
            };
            ResponseBase<ActivityCreateResponse> activityCreateResponse = new ResponseBase<ActivityCreateResponse>()
            {
                Data = new ActivityCreateResponse() { Id = _wellTestingEmission.JobId }
            };
            _httpClientWrapperSvc.Setup(x => ), It.IsAny<string>()))
                .Returns(Task.FromResult(activityBatchQueryResponse));
            //The method actively throws an exception during execution, and only throws an exception the first time it is executed, and then executes normally.
            _httpClientWrapperSvc.Setup(x => ), It.IsAny<string>()))
                .Returns(Task.FromResult(activityCreateResponse))
                .Callback(() =>
                {
                    calls + + ;
                    if (calls == 1)
                    {
                        throw new HttpRequestException("", new Exception(), HttpStatusCode.Unauthorized);
                    }
                });
            var exception = await Record.ExceptionAsync(async () => await _sut.InsertOrUpdateActivityAsync(_fileMetadataId, _wellTestingEmission, _operationalActivity, _token));
            exception.Should().BeNull();
        }

        [Fact]
        public async Task InsertOrUpdateActivity_ShouldSuccess_WhenUpdate()
        {
            ResponseBase<ActivityBatchQueryResponse> activityBatchQueryResponse = new ResponseBase<ActivityBatchQueryResponse>()
            {
                Data = new ActivityBatchQueryResponse()
                {
                    //TotalCount>0 and the Results collection is not empty
                    TotalCount = 1,
                    Results = new List<ActivityBatchQueryItem>() { new ActivityBatchQueryItem() { Id = _wellTestingEmission.JobId } }
                }
            };
            _httpClientWrapperSvc.Setup(x => ), It.IsAny<string>()))
                .ReturnsAsync(activityBatchQueryResponse);
            _httpClientWrapperSvc.Setup(x => <string>()))
                .ReturnsAsync(_wellTestingEmission.JobId);
            var exception = await Record.ExceptionAsync(async () => await _sut.InsertOrUpdateActivityAsync(_fileMetadataId, _wellTestingEmission, _operationalActivity, _token));
            exception.Should().BeNull();
        }

        [Fact]
        public async Task InsertOrUpdateActivity_ShouldSuccess_WhenUpdate_Met401()
        {
            int calls = 0;
            ResponseBase<ActivityBatchQueryResponse> activityBatchQueryResponse = new ResponseBase<ActivityBatchQueryResponse>()
            {
                Data = new ActivityBatchQueryResponse()
                {
                    //TotalCount>0 and the Results collection is not empty
                    TotalCount = 1,
                    Results = new List<ActivityBatchQueryItem>() { new ActivityBatchQueryItem() { Id = _wellTestingEmission.JobId } }
                }
            };
            _httpClientWrapperSvc.Setup(x => ), It.IsAny<string>()))
                .ReturnsAsync(activityBatchQueryResponse);
            _httpClientWrapperSvc.Setup(x => <string>()))
                .ReturnsAsync(_wellTestingEmission.JobId)
                .Callback(() =>
                {
                    calls + + ;
                    if (calls == 1)
                    {
                        throw new HttpRequestException("", new Exception(), HttpStatusCode.Unauthorized);
                    }
                });
            var exception = await Record.ExceptionAsync(async () => await _sut.InsertOrUpdateActivityAsync(_fileMetadataId, _wellTestingEmission, _operationalActivity, _token));
            exception.Should().BeNull();
        }

        private void InitOptions()
        {
            ReferencingServices referencingServices = new ReferencingServices()
            {
                DataPartitionId = "DataPartition-Id",
                FileServiceURL = "https://global.FileService.URL",
                ActivityServiceURL = "https://global.ActivityService.URL",
                ProcessStatusServiceURL = "https://global.ProcessStatusService.URL"
            };
            //Variables of type IOptions<T> obtain actual parameter values through the Value attribute, so the Value attribute must be overridden here.
            _referencingServices.Setup(x => x.Value).Returns(referencingServices);
        }

        private void InitServices()
        {
            //ISauthService.GetToken() is called in AnalyzerService. If GetToken() is not rewritten, the return value of all places where this method is used is null (the return value is string type)
            _sauthService.Setup(x => x.GetToken()).ReturnsAsync(_token);
        }
    }
}

5. Summary

After completing the above two unit tests, the processing of other Services is also similar, so it is not fully displayed here.

Different companies have slightly different definitions of unit testing. What we discuss here is the xUnit + Moq data method. Before this, I have read many bloggers’ articles about unit testing, but most of them were relatively simple, such as: The test method is just addition, subtraction, multiplication and division. Assert compares the results to see if they are correct. Such articles are difficult to apply to projects. It was only after I came into contact with unit testing that I was lucky enough to sort out such a set of things. If it is helpful to everyone Please like, follow and comment for support, thank you.