[ASP.NET Core] 為WebApi加上整合測試

為WebApi加上整合測試

每次都要翻翻舊專案回憶,乾脆寫一篇筆記

從發出一個request開始,開始測試API的各個部份,

並且驗證各個功能整合起來是正確的,直接針對需求做驗證


1.從一個新建的WebApi範本專案開始,額外用到了幾個nuget package在測試專案上,輔助測試

dotnet package add Microsoft.AspNet.WebApi.Client
dotnet package add FluentAssertions
dotnet package add NSubstitute

2.為測試專案加上nuget package

dotnet package add Microsoft.AspNetCore.Mvc.Testing

3.新增一個TestBase

  • 必須實作 IClassFixture<WebApplicationFactory<Startup>>
  • Startup為待測試專案的Startup
  • 並且從ctor注入一個 WebApplicationFactory<Startup>
    public class TestBase
        : IClassFixture<WebApplicationFactory<Startup>>
    {
        private readonly WebApplicationFactory<Startup> _applicationFactory;

        public TestBase(WebApplicationFactory<Startup> applicationFactory)
        {
            _applicationFactory = applicationFactory;
        }
    }

4.接著完成第一個測試(這時的Test Class繼承了TestBase

  • 從Base的 _applicationFactory 可以 Create 一個 HttpClient,後面的使用就跟一般的HttpClient一樣了
  • 發出request,目標是待測試的Api
  • 拿回response,並驗證結果
        [Fact]
        public async Task Test1()
        {
            var httpClient = _applicationFactory.CreateClient();

            var message = await httpClient.GetAsync("/WeatherForecast");
            var list = await message.Content.ReadAsAsync<List<WeatherForecast>>();

            message.StatusCode.Should().Be(HttpStatusCode.OK);
            list.Count.Should().Be(5);
        }

5.接著讓程式碼稍微複雜一點,抽出一個Service

        //Controller
        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            return _testService.WeatherForecasts();
        }
    //Service
    public class TestService
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };

        public IEnumerable<WeatherForecast> WeatherForecasts()
        {
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
                             {
                                 Date = DateTime.Now.AddDays(index),
                                 TemperatureC = rng.Next(-20, 55),
                                 Summary = Summaries[rng.Next(Summaries.Length)]
                             })
                             .ToArray();
        }
    }

6.接著跑個測試,仍然會是通過的

7.接著假設這個Service是我們無法控制的服務,需要模擬掉他

8.在TestBase上加上一個方法,讓我們可以在建立HttpClient時一併替換掉一些在DI註冊的服務

  • 把create出來的webHost保存起來,後續會使用到
        protected HttpClient CreateHttpClient(Action<IServiceCollection> configureServices)
        {
            _webHost = _factory.WithWebHostBuilder(builder =>
            {
                if (configureServices != null)
                {
                    builder.ConfigureServices(configureServices);
                }
            });
            return _webHost.CreateClient();
        }

9.回到測試,現在可以把Service給模擬掉了

        [Fact]
        public async Task Test1()
        {
            var testService = Substitute.For<ITestService>();
            testService.WeatherForecasts().Returns(new List<WeatherForecast>
            {
                new WeatherForecast(),
            });
            
            var httpClient = CreateHttpClient(collection =>
            {
                collection.AddScoped(r => testService);
            });

            var message = await httpClient.GetAsync("/WeatherForecast");
            var list = await message.Content.ReadAsAsync<List<WeatherForecast>>();

            message.StatusCode.Should().Be(HttpStatusCode.OK);
            list.Count.Should().Be(1);
        }

10.現在已經可以針對API做測試,也可以模擬掉無法控制的服務,接著再加上DB的存取

11.調整一下WebApi

  • 在WebApi加上nuget package,為了方便用Sqlite
    dotnet add package Microsoft.EntityFrameworkCore
    dotnet add package Microsoft.EntityFrameworkCore.Sqlite
  • 新增DbContext

        public class MyDbContext : DbContext
        {
            public MyDbContext(DbContextOptions<MyDbContext> options)
                : base(options)
            {
            }
    
            public DbSet<Member> Member { get; set; }
    
            protected override void OnModelCreating(ModelBuilder modelBuilder)
            {
                modelBuilder.Entity<Member>().HasKey(r => r.Name);
            }
        }
    
  • Model

        public class Member
        {
            public string Name { get; set; }
        }
    
  • Startup

                services.AddDbContext<MyDbContext>(builder =>
                {
                    builder.UseSqlite("Data Source=my.db");
                });
    
  • 在Controller新增一個Action,對Db操作

            [HttpGet]
            public int GetDb()
            {
                _dbContext.Member.Add(new Member()
                {
                    Name = "Controller"
                });
                _dbContext.SaveChanges();
    
                return _dbContext.Member.Count();
            }
    

12.在測試專案上,先把DataBase替換成Memory方便測試(也可以成其他資料庫或是Sqlite)

        protected HttpClient CreateHttpClient(Action<IServiceCollection> configureServices)
        {
            _webHost = _factory.WithWebHostBuilder(builder =>
            {
                builder.UseEnvironment("Test");
                if (configureServices != null)
                {
                    builder.ConfigureTestServices(configureServices);
                }

                var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<MyDbContext>));
                if (descriptor != null)
                {
                    services.Remove(descriptor);
                }

                //---------------------------------
                builder.ConfigureServices(services =>
                {
                    services.AddDbContext<MyDbContext>(options =>
                    {
                        options.UseInMemoryDatabase("Test");
                    });

                    var serviceProvider = services.BuildServiceProvider();
                    using (var scope = serviceProvider.CreateScope())
                    {
                        var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
                        db.Database.EnsureDeleted();
                        db.Database.EnsureCreated();
                        InitDb(db);
                    }
                });
                //---------------------------------
            });
            return _webHost.CreateClient();
        }

13.並且在TestBase加上一個方法,好讓我們針對DataBase的資料做事後的驗證

  • 這邊也可以改成從DI容器拿出特定的服務做其他的驗證,視個人需求調整
        public void DbOperator(Action<MyDbContext> action)
        {
            using (var serviceScope = _webHost.Services.CreateScope())
            {
                var myDbContext = serviceScope.ServiceProvider.GetRequiredService<MyDbContext>();
                action(myDbContext);
            }
        }

14.最後調整下測試

  • 發出request測試方法GetDb
  • 執行完畢後僅驗證回應的HttpStatusCode.OK
  • 從WebHost內拿出DB並驗證有Add一筆資料進去,且Name相符
        [Fact]
        public async Task TestDb()
        {
            var httpClient = CreateHttpClient(null);

            var message = await httpClient.GetAsync("/WeatherForecast/GetDb");

            message.StatusCode.Should().Be(HttpStatusCode.OK);
            DbOperator(context =>
            {
                var member = context.Member.FirstOrDefault(r => r.Name == "Controller");
                member.Should().NotBeNull();
            });
        }

平常開發上都會加上E2E測試和不少的單元測試,不得已的情況下才會選擇把E2E下的某些服務模擬掉(有些服務要模擬掉也不是太容易的事情阿......)

後續會再寫一篇針對MVC的整合測試,在實務上試了一陣子,還是WebApi的測試親切多了......


Microsoft Docs https://docs.microsoft.com/zh-tw/aspnet/core/test/integration-tests?view=aspnetcore-3.1

Sample Code https://github.com/ianChen806/IntegrationTestSample