.NET Core 3.0 单元测试与 Asp.Net Core 3.0 集成测试

单元测试与集成测试

测试必要性说明

相信你们在看到单元测试与集成测试这个标题时,会有不少感慨,咱们无数次的在实践中提到要作单元测试、集成测试,可是大多数项目都没有作或者仅建了项目文件。这里有客观缘由,已经接近交付日期了,咱们没时间作白盒测试了。也有主观缘由,面对业务复杂的代码咱们不知道如何入手作单元测试,不如就留给黑盒测试吧。可是,当咱们的代码没法进行单元测试的时候,每每就是代码开始散发出坏味道的时候。久而久之,将欠下技术债务。在实践过程当中,技术债务经常会存在,关键在于什么时候偿还,如何偿还。html

process

上图说明了随着时间的推移开发/维护难度的变化。git

测试框架选择

在 .NET Core 中,提供了 xUnit 、NUnit 、 MSTest 三种单元测试框架。github

MSTest UNnit xUnit 说明 提示
[TestMethod] [Test] [Fact] 标记一个测试方法
[TestClass] [TestFixture] n/a 标记一个 Class 为测试类,xUnit 不须要标记特性,它将查找程序集下全部 Public 的类
[ExpectedException] [ExpectedException] Assert.Throws 或者 Record.Exception xUnit 去掉了 ExpectedException 特性,支持 Assert.Throws
[TestInitialize] [SetUp] Constructor 咱们认为使用 [SetUp] 一般来讲很差。可是,你能够实现一个无参构造器直接替换 [SetUp]。 有时咱们会在多个测试方法中用到相同的变量,熟悉重构的咱们会提取公共变量,并在构造器中初始化。可是,这里我要强调的是:在测试中,不要提取公共变量,这会破坏每一个测试用例的隔离性以及单一职责原则。
[TestCleanup] [TearDown] IDisposable.Dispose 咱们认为使用 [TearDown] 一般来讲很差。可是你能够实现 IDisposable.Dispose 以替换。 [TearDown] 和 [SetUp] 一般成对出现,在 [SetUp] 中初始化一些变量,则在 [TearDown] 中销毁这些变量。
[ClassInitialize] [TestFixtureSetUp] IClassFixture< T > 共用前置类 这里 IClassFixture< T > 替换了 IUseFixture< T > ,参考
[ClassCleanup] [TestFixtureTearDown] IClassFixture< T > 共用后置类 同上
[Ignore] [Ignore] [Fact(Skip="reason")] 在 [Fact] 特性中设置 Skip 参数以临时跳过测试
[Timeout] [Timeout] [Fact(Timeout=n)] 在 [Fact] 特性中设置一个 Timeout 参数,当容许时间太长时引发测试失败。注意,xUnit 的单位时毫秒。
[DataSource] n/a [Theory], [XxxData] Theory(数据驱动测试),表示执行相同代码,但具备不一样输入参数的测试套件 这个特性能够帮助咱们少写不少代码。

以上写了 MSTest 、UNnit 、 xUnit 的特性以及比较,能够看出 xUnit 在使用上相对其它两个框架来讲提供更多的便利性。可是这里最终实现仍是看我的习惯以选择。数据库

单元测试

  1. 新建单元测试项目
    testjson

  2. 新建 Class
    testapi

  3. 添加测试方法网络

    /// <summary>
            /// 添加地址
            /// </summary>
            /// <returns></returns>
            [Fact]
            public async Task Add_Address_ReturnZero()
            {
                DbContextOptions<AddressContext> options = new DbContextOptionsBuilder<AddressContext>().UseInMemoryDatabase("Add_Address_Database").Options;
                var addressContext = new AddressContext(options);
    
                var createAddress = new AddressCreateDto
                {
                    City = "昆明",
                    County = "五华区",
                    Province = "云南省"
                };
                var stubAddressRepository = new Mock<IRepository<Domain.Address>>();
                var stubProvinceRepository = new Mock<IRepository<Province>>();
                var addressUnitOfWork = new AddressUnitOfWork<AddressContext>(addressContext);
    
                var stubAddressService = new AddressServiceImpl.AddressServiceImpl(stubAddressRepository.Object, stubProvinceRepository.Object, addressUnitOfWork);
                await stubAddressService.CreateAddressAsync(createAddress);
                int addressAmountActual = await addressContext.Addresses.CountAsync();
                Assert.Equal(1, addressAmountActual);
            }
    • 测试方法的名字包含了测试目的、测试场景以及预期行为。
    • UseInMemoryDatabase 指明使用内存数据库。
    • 建立 createAddress 对象。
    • 建立 Stub 。在单元测试中经常会提到几个概念 Stub , Mock 和 Fake ,那么在应用中咱们该如何选择呢?
      • Fake - Fake 一般被用于描述 Mock 或 Stub ,如何判断它是 Stub 仍是 Mock 依赖于使用上下文,换句话说,Fake 便是 Stub 也是 Mock 。
      • Stub - Stub 是系统中现有依赖项的可控替代品。经过使用 Stub ,你能够不用处理依赖直接测试你的代码。默认状况下, 伪造对象以stub 开头。
      • Mock - Mock 对象是系统中的伪造对象,它决定单元测试是否经过或失败。Mock 会以 Fake 开头,直到被断言为止。
    • Moq4 ,使用 Moq4 模拟咱们在项目中依赖对象。参考
  4. 打开视图 -> 测试资源管理器。
    测试资源管理器app

  5. 点击运行,获得测试结果。
    测试结果框架

  6. 至此,一个单元测试结束。async

集成测试

集成测试确保应用的组件功能在包含应用的基础支持下是正确的,例如:数据库、文件系统、网络等。

  1. 新建集成测试项目。
    新建集成测试项目

  2. 添加工具类 Utilities 。

    using System.Collections.Generic;
    using AddressEFRepository;
    
    namespace Address.IntegrationTest
    {
        public static class Utilities
        {
            public static void InitializeDbForTests(AddressContext db)
            {
                List<Domain.Address> addresses = GetSeedingAddresses();
                db.Addresses.AddRange(addresses);
                db.SaveChanges();
            }
    
            public static void ReinitializeDbForTests(AddressContext db)
            {
                db.Addresses.RemoveRange(db.Addresses);
                InitializeDbForTests(db);
            }
    
            public static List<Domain.Address> GetSeedingAddresses()
            {
                return new List<Domain.Address>
                {
                    new Domain.Address
                    {
                        City = "贵阳",
                        County = "测试县",
                        Province = "贵州省"
                    },
                    new Domain.Address
                    {
                        City = "昆明市",
                        County = "武定县",
                        Province = "云南省"
                    },
                    new Domain.Address
                    {
                        City = "昆明市",
                        County = "五华区",
                        Province = "云南省"
                    }
                };
            }
        }
    }
  3. 添加 CustomWebApplicationFactory 类,

    using System;
     using System.IO;
     using System.Linq;
     using AddressEFRepository;
     using Microsoft.AspNetCore.Hosting;
     using Microsoft.AspNetCore.Mvc.Testing;
     using Microsoft.EntityFrameworkCore;
     using Microsoft.Extensions.Configuration;
     using Microsoft.Extensions.DependencyInjection;
     using Microsoft.Extensions.Logging;
    
     namespace Address.IntegrationTest
     {
         public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
         {
             protected override void ConfigureWebHost(IWebHostBuilder builder)
             {
                 string projectDir = Directory.GetCurrentDirectory();
                 string configPath = Path.Combine(projectDir, "appsettings.json");
                 builder.ConfigureAppConfiguration((context, conf) =>
                 {
                     conf.AddJsonFile(configPath);
                 });
    
                 builder.ConfigureServices(services =>
                 {
                     ServiceDescriptor descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AddressContext>));
    
                     if (descriptor != null)
                     {
                         services.Remove(descriptor);
                     }
    
                     services.AddDbContextPool<AddressContext>((options, context) =>
                     {
                         //var configuration = options.GetRequiredService<IConfiguration>();
                         //string connectionString = configuration.GetConnectionString("TestAddressDb");
                         //context.UseMySql(connectionString);
                         context.UseInMemoryDatabase("InMemoryDbForTesting");
    
                     });
    
                     // Build the service provider.
                     ServiceProvider sp = services.BuildServiceProvider();
                     // Create a scope to obtain a reference to the database
                     // context (ApplicationDbContext).
                     using IServiceScope scope = sp.CreateScope();
                     IServiceProvider scopedServices = scope.ServiceProvider;
                     var db = scopedServices.GetRequiredService<AddressContext>();
                     var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
    
                     // Ensure the database is created.
                     db.Database.EnsureCreated();
    
                     try
                     {
                         // Seed the database with test data.
                         Utilities.ReinitializeDbForTests(db);
                     }
                     catch (Exception ex)
                     {
                         logger.LogError(ex, "An error occurred seeding the " + "database with test messages. Error: {Message}", ex.Message);
                     }
                 });
             }
         }
     }
    • 这里为何要添加 CustomWebApplicationFactory 呢?
      WebApplicationFactory 是用于在内存中引导应用程序进行端到端功能测试的工厂。经过引入自定义 CustomWebApplicationFactory 类重写 ConfigureWebHost 方法,咱们能够重写咱们在 StartUp 中定义的内容,换句话说咱们能够在测试环境中使用正式环境的配置,同时能够重写,例如:数据库配置,数据初始化等等。
    • 如何准备测试数据?
      咱们可使用数据种子的方式加入数据,数据种子能够针对每一个集成测试作数据准备。
    • 除了内存数据库,还可使用其余数据库进行测试吗?
      能够。
  4. 添加集成测试 AddressControllerIntegrationTest 类。

    using System.Collections.Generic;
     using System.Linq;
     using System.Net.Http;
     using System.Threading.Tasks;
     using Address.Api;
     using Microsoft.AspNetCore.Mvc.Testing;
     using Newtonsoft.Json;
     using Xunit;
    
     namespace Address.IntegrationTest
     {
         public class AddressControllerIntegrationTest : IClassFixture<CustomWebApplicationFactory<Startup>>
         {
             public AddressControllerIntegrationTest(CustomWebApplicationFactory<Startup> factory)
             {
                 _client = factory.CreateClient(new WebApplicationFactoryClientOptions
                 {
                     AllowAutoRedirect = false
                 });
             }
    
             private readonly HttpClient _client;
    
             [Fact]
             public async Task Get_AllAddressAndRetrieveAddress()
             {
                 const string allAddressUri = "/api/Address/GetAll";
                 HttpResponseMessage allAddressesHttpResponse = await _client.GetAsync(allAddressUri);
    
                 allAddressesHttpResponse.EnsureSuccessStatusCode();
    
                 string allAddressStringResponse = await allAddressesHttpResponse.Content.ReadAsStringAsync();
                 var addresses = JsonConvert.DeserializeObject<IList<AddressDto.AddressDto>>(allAddressStringResponse);
                 Assert.Equal(3, addresses.Count);
    
                 AddressDto.AddressDto address = addresses.First();
                 string retrieveUri = $"/api/Address/Retrieve?id={address.ID}";
                 HttpResponseMessage addressHttpResponse = await _client.GetAsync(retrieveUri);
    
                 // Must be successful.
                 addressHttpResponse.EnsureSuccessStatusCode();
    
                 // Deserialize and examine results.
                 string addressStringResponse = await addressHttpResponse.Content.ReadAsStringAsync();
                 var addressResult = JsonConvert.DeserializeObject<AddressDto.AddressDto>(addressStringResponse);
                 Assert.Equal(address.ID, addressResult.ID);
                 Assert.Equal(address.Province, addressResult.Province);
                 Assert.Equal(address.City, addressResult.City);
                 Assert.Equal(address.County, addressResult.County);
             }
         }
     }
  5. 在测试资源管理器中运行集成测试方法。
    测试资源管理器
    运行集成测试

  6. 结果。
    集成测试结果

  7. 至此,集成测试完成。须要注意的是,集成测试每每耗时比较多,因此建议能使用单元测试时就不要使用集成测试。

总结:当咱们写单元测试时,通常不会同时存在 Stub 和 Mock 两种模拟对象,当同时出现这两种对象时,代表单元测试写的不合理,或者业务写的太过庞大,同时,咱们能够经过单元测试驱动业务代码重构。当须要重构时,咱们应尽可能完成重构,不要留下欠下过多技术债务。集成测试有自身的复杂度存在,咱们不要节约时间而打破单一职责原则,不然会引起不可预期后果。为了应对业务修改,咱们应该在业务修改之后,进行回归测试,回归测试主要关注被修改的业务部分,同时测试用例若是有没要能够重写,运行整个和修改业务有关的测试用例集。

源码地址