首頁 > 軟體

ASP.NET Core專案使用xUnit進行單元測試

2022-03-25 19:00:22

一、前言

在以前的.NET Framework專案中,我們也寫過一些單元測試的專案,而在ASP.NET Core 這種Web或者API應用程式中要做單元測試是很方便的。

這篇文章主要講解如何使用xUnit對ASP.NET Core應用程式做單元測試。.NET Core中常用的測試工具還有NUnit和MSTest。

xUnit是一個測試框架,可以針對.net/.net core專案進行測試。測試專案需要參照被測試的專案,從而對其進行測試。測試專案同時需要參照xUnit庫。測試編寫好後,用Test Runner來執行測試。Test Runner可以讀取測試程式碼,並且會知道我們所使用的測試框架,然後執行,並顯示結果。目前可用的Test Runner包括vs自帶的Test Explorer,或者dotnet core命令列,以及第三方工具,例如resharper等。

xUnit可以支援多種平臺的測試:

  • .NET Framework
  • .NET Core
  • .NET Standard
  • UWP
  • Xamarin

二、建立範例專案

為了使範例專案更加的貼近真實的專案開發,這裡採用分層的方式建立一個範例專案,建立完成後的專案結構如下圖所示:

 下面講解一下每層的作用,按照從上往下的順序:

  • TestDemo:從名字就可以看出來,這是一個單元測試的專案,針對控制器進行測試。
  • UnitTest.Data:資料存取,封裝與EntityFrameworkCore相關的操作。
  • UnitTest.IRepository:泛型倉儲介面,封裝基礎的增刪改查。
  • UnitTest.Model:實體層,定義專案中使用到的所有實體。
  • UnitTest.Repository:泛型倉儲介面實現層,實現介面裡面定義的方法。
  • UnitTestDemo:ASP.NET Core WebApi,提供API介面。

1、UnitTest.Model

實體層裡面只有一個Student類:

using System;
using System.Collections.Generic;
using System.Text;

namespace UnitTest.Model
{
    public class Student
    {
        public int ID { get; set; }

        public string Name { get; set; }

        public int Age { get; set; }

        public string Gender { get; set; }
    }
}

2、UnitTest.Data

裡面封裝與EF Core有關的操作,首先需要引入Microsoft.EntityFrameworkCore、Microsoft.EntityFrameworkCore.SqlServer、Microsoft.EntityFrameworkCore.Tools三個NuGet包,直接在管理NuGet程式包裡面引入,這裡不在講述。

引入相關NuGet包以後,我們建立資料上下文類,該類繼承自EF Core的DbContext,裡面設定表名和一些屬性:

using Microsoft.EntityFrameworkCore;
using UnitTest.Model;

namespace UnitTest.Data
{
    /// <summary>
    /// 資料上下文類
    /// </summary>
    public class AppDbContext : DbContext
    {
        /// <summary>
        /// 通過建構函式給父類別構造傳參
        /// </summary>
        /// <param name="options"></param>
        public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
        {

        }

        public DbSet<Student> Students { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Student>().ToTable("T_Student");
            modelBuilder.Entity<Student>().HasKey(p => p.ID);
            modelBuilder.Entity<Student>().Property(p => p.Name).HasMaxLength(32);

            // 新增種子資料
            modelBuilder.Entity<Student>().HasData(
                new Student()
                {
                    ID = 1,
                    Name = "測試1",
                    Age = 20,
                    Gender = "男"
                },
                new Student()
                {
                    ID = 2,
                    Name = "測試2",
                    Age = 22,
                    Gender = "女"
                },
                new Student()
                {
                    ID = 3,
                    Name = "測試3",
                    Age = 23,
                    Gender = "男"
                });
            base.OnModelCreating(modelBuilder);
        }
    }
}

這裡採用資料遷移的方式生成資料庫,需要在API專案中引入Microsoft.EntityFrameworkCore、Microsoft.EntityFrameworkCore.SqlServer、Microsoft.EntityFrameworkCore.Tools三個NuGet包。引入方式同上。

然後在API專案的appsettings.json檔案裡面新增資料庫連結字串:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  // 資料庫連線字串
  "ConnectionString": {
    "DbConnection": "Initial Catalog=TestDb;User Id=sa;Password=1234;Data Source=.;Connection Timeout=10;"
  }
}

在JSON檔案中新增完連線字串以後,修改Startup類的ConfigureServices方法,在裡面設定使用在json檔案中新增的連線字串:

// 新增資料庫連線字串
services.AddDbContext<AppDbContext>(options => 
{
    options.UseSqlServer(Configuration.GetSection("ConnectionString").GetSection("DbConnection").Value);
});

這樣就可以使用資料遷移的方式生成資料庫了。

3、UnitTest.IRepository

該專案中使用泛型倉儲,定義一個泛型倉儲介面:

using System.Collections.Generic;
using System.Threading.Tasks;

namespace UnitTest.IRepository
{
    public interface IRepository<T> where T:class,new()
    {
        Task<List<T>> GetList();

        Task<int?> Add(T entity);

        Task<int?> Update(T entity);

        Task<int?> Delete(T entity);
    }
}

然後在定義IStudentRepository介面繼承自IRepository泛型介面:

using UnitTest.Model;

namespace UnitTest.IRepository
{
    public interface IStudentRepository: IRepository<Student>
    {
    }
}

4、UnitTest.Repository

這裡是實現上面定義的倉儲介面:

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnitTest.Data;
using UnitTest.IRepository;
using UnitTest.Model;

namespace UnitTest.Repository
{
    public class StudentRepository : IStudentRepository
    {
        private readonly AppDbContext _dbContext;

        /// <summary>
        /// 通過建構函式實現依賴注入
        /// </summary>
        /// <param name="dbContext"></param>
        public StudentRepository(AppDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        public async Task<int?> Add(Student entity)
        {
            _dbContext.Students.Add(entity);
            return await _dbContext.SaveChangesAsync();
        }

        public async Task<int?> Delete(Student entity)
        {
            _dbContext.Students.Remove(entity);
            return await _dbContext.SaveChangesAsync();
        }

        public async Task<List<Student>> GetList()
        {
            List<Student> list = new List<Student>();

            list = await Task.Run<List<Student>>(() => 
            {
                return _dbContext.Students.ToList();
            });
          
            return list;
        }

        public async Task<int?> Update(Student entity)
        {
            Student student = _dbContext.Students.Find(entity.ID);
            if (student != null)
            {
                student.Name = entity.Name;
                student.Age = entity.Age;
                student.Gender = entity.Gender;
                _dbContext.Entry<Student>(student).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
                return await _dbContext.SaveChangesAsync();
            }
            return 0;
        }
    }
}

5、UnitTestDemo

先新增一個Value控制器,裡面只有一個Get方法,而且沒有任何的依賴關係,先進行最簡單的測試:

using Microsoft.AspNetCore.Mvc;

namespace UnitTestDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ValueController : ControllerBase
    {
        [HttpGet("{id}")]
        public ActionResult<string> Get(int id)
        {
            return $"Para is {id}";
        }
    }
}

6、TestDemo

我們在新增測試專案的時候,直接選擇使用xUnit測試專案,如下圖所示:

這樣專案建立完成以後,就會自動新增xUnit的參照:

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
  </ItemGroup>

但要測試 ASP.NET Core 應用還需要新增兩個 NuGet 包:

Install-Package Microsoft.AspNetCore.App
Install-Package Microsoft.AspNetCore.TestHost

上面是使用命令的方式進行安裝,也可以在管理NuGet程式包裡面進行搜尋,然後安裝。

千萬不要忘記還要引入要測試的專案。最後的專案引入是這樣的:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>

    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
    <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="3.1.2" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
    <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
    <PackageReference Include="coverlet.collector" Version="1.0.1" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..UnitTest.ModelUnitTest.Model.csproj" />
    <ProjectReference Include="..UnitTestDemoUnitTestDemo.csproj" />
  </ItemGroup>

</Project>

都新增完以後,重新編譯專案,保證生成沒有錯誤。

三、編寫單元測試

單元測試按照從上往下的順序,一般分為三個階段:

  • Arrange:準備階段。這個階段做一些準備工作,例如建立物件範例,初始化資料等。
  • Act:行為階段。這個階段是用準備好的資料去呼叫要測試的方法。
  • Assert:斷定階段。這個階段就是把呼叫目標方法的返回值和預期的值進行比較,如果和預期值一致則測試通過,否則測試失敗。

我們在API專案中新增了一個Value控制器,我們以Get方法作為測試目標。一般一個單元測試方法就是一個測試用例。

我們在測試專案中新增一個ValueTest測試類,然後編寫一個單元測試方法,這裡是採用模擬HTTPClient傳送Http請求的方式進行測試:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using UnitTestDemo;
using Xunit;

namespace TestDemo
{
    public class ValueTests
    {
        public HttpClient _client { get; }

        /// <summary>
        /// 構造方法
        /// </summary>
        public ValueTests()
        {
            var server = new TestServer(WebHost.CreateDefaultBuilder()
           .UseStartup<Startup>());
            _client = server.CreateClient();
        }

        [Fact]
        public async Task GetById_ShouldBe_Ok()
        {
            // 1、Arrange
            var id = 1;

            // 2、Act
            // 呼叫非同步的Get方法
            var response = await _client.GetAsync($"/api/value/{id}");

            // 3、Assert
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

        }
    }
}

我們在建構函式中,通過TestServer拿到一個HttpClient物件,用它來模擬Http請求。我們寫了一個測試用例,完整演示了單元測試的Arrange、Act和Assert三個步驟。

1、執行單元測試

單元測試用例寫好以後,開啟“測試資源管理器”:

在底部就可以看到測試資源管理器了:

在要測試的方法上面右鍵,選擇“執行測試”就可以進行測試了:

注意觀察測試方法前面圖示的顏色,目前是藍色的,表示測試用例還沒有執行過:

測試用例結束以後,我們在測試資源管理器裡面可以看到結果:

綠色表示測試通過。我們還可以看到執行測試用例消耗的時間。

如果測試結果和預期結果一致,那麼測試用例前面圖示的顏色也會變成綠色:

如果測試結果和預期結果不一致就會顯示紅色,然後需要修改程式碼直到出現綠色圖示。我們修改測試用例,模擬測試失敗的情況:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using UnitTestDemo;
using Xunit;

namespace TestDemo
{
    public class ValueTests
    {
        public HttpClient _client { get; }

        /// <summary>
        /// 構造方法
        /// </summary>
        public ValueTests()
        {
            var server = new TestServer(WebHost.CreateDefaultBuilder()
           .UseStartup<Startup>());
            _client = server.CreateClient();
        }

        [Fact]
        public async Task GetById_ShouldBe_Ok()
        {
            // 1、Arrange
            var id = 1;

            // 2、Act
            // 呼叫非同步的Get方法
            var response = await _client.GetAsync($"/api/value/{id}");

            //// 3、Assert
            //Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            // 3、Assert
            // 模擬測試失敗
            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

        }
    }
}

然後執行測試用例:

2、偵錯單元測試

我們也可以通過新增斷點的方式在測試用例中進行偵錯。偵錯單元測試很簡單,只需要在要偵錯的方法上面右鍵選擇“偵錯測試”,如下圖所示:

其它操作就跟偵錯普通方法一樣。

除了新增斷點偵錯,我們還可以採用列印紀錄檔的方法來快速偵錯,xUnit可以很方便地做到這一點。我們修改ValueTest類:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using UnitTestDemo;
using Xunit;
using Xunit.Abstractions;

namespace TestDemo
{
    public class ValueTests
    {
        public HttpClient _client { get; }
        public ITestOutputHelper Output { get; }

        /// <summary>
        /// 構造方法
        /// </summary>
        public ValueTests(ITestOutputHelper outputHelper)
        {
            var server = new TestServer(WebHost.CreateDefaultBuilder()
           .UseStartup<Startup>());
            _client = server.CreateClient();
            Output = outputHelper;
        }

        [Fact]
        public async Task GetById_ShouldBe_Ok()
        {
            // 1、Arrange
            var id = 1;

            // 2、Act
            // 呼叫非同步的Get方法
            var response = await _client.GetAsync($"/api/value/{id}");

            // 3、Assert
            // 模擬測試失敗
            //Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

            // 輸出返回資訊
            // Output
            var responseText = await response.Content.ReadAsStringAsync();
            Output.WriteLine(responseText);

            // 3、Assert
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

        }
    }
}

這裡我們在建構函式中新增了 ITestOutputHelper 引數,xUnit 會將一個實現此介面的範例注入進來。拿到這個範例後,我們就可以用它來輸出紀錄檔了。執行(注意不是 Debug)此方法,執行結束後在測試資源管理器裡面檢視:

點選就可以看到輸出的紀錄檔了:

在上面的例子中,我們是使用的簡單的Value控制器進行測試,控制器裡面沒有其他依賴關係,如果控制器裡面有依賴關係該如何測試呢?方法還是一樣的,我們新建一個Student控制器,裡面依賴IStudentRepository介面,程式碼如下:

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using UnitTest.IRepository;
using UnitTest.Model;

namespace UnitTestDemo.Controllers
{
    [Route("api/student")]
    [ApiController]
    public class StudentController : ControllerBase
    {
        private readonly IStudentRepository _repository;

        /// <summary>
        /// 通過建構函式注入
        /// </summary>
        /// <param name="repository"></param>
        public StudentController(IStudentRepository repository)
        {
            _repository = repository;
        }

        /// <summary>
        /// get方法
        /// </summary>
        /// <returns></returns>
        [HttpGet]
        public async Task<ActionResult<List<Student>>> Get()
        {
            return await _repository.GetList();
        }
    }
}

然後在Startup類的ConfigureServices方法中注入:

public void ConfigureServices(IServiceCollection services)
{
    // 新增資料庫連線字串
    services.AddDbContext<AppDbContext>(options => 
    {
        options.UseSqlServer(Configuration.GetSection("ConnectionString").GetSection("DbConnection").Value);
    });
    // 新增依賴注入到容器中
    services.AddScoped<IStudentRepository, StudentRepository>();
    services.AddControllers();
}

在單元測試專案中新增StudentTest類:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Newtonsoft.Json;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using UnitTest.Model;
using UnitTestDemo;
using Xunit;
using Xunit.Abstractions;

namespace TestDemo
{
    public class StudentTest
    {
        public HttpClient Client { get; }
        public ITestOutputHelper Output { get; }
        public StudentTest(ITestOutputHelper outputHelper)
        {
            var server = new TestServer(WebHost.CreateDefaultBuilder()
           .UseStartup<Startup>());
            Client = server.CreateClient();
            Output = outputHelper;
        }

        [Fact]
        public async Task Get_ShouldBe_Ok()
        {
            // 2、Act
            var response = await Client.GetAsync($"api/student");

            // Output
            string context = await response.Content.ReadAsStringAsync();
            Output.WriteLine(context);
            List<Student> list = JsonConvert.DeserializeObject<List<Student>>(context);

            // Assert
            Assert.Equal(3, list.Count);
        }
    }
}

然後執行單元測試:

可以看到,控制器裡面如果有依賴關係,也是可以使用這種方式進行測試的。

Post方法也可以使用同樣的方式進行測試,修改控制器,新增Post方法:

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using UnitTest.IRepository;
using UnitTest.Model;

namespace UnitTestDemo.Controllers
{
    [Route("api/student")]
    [ApiController]
    public class StudentController : ControllerBase
    {
        private readonly IStudentRepository _repository;

        /// <summary>
        /// 通過建構函式注入
        /// </summary>
        /// <param name="repository"></param>
        public StudentController(IStudentRepository repository)
        {
            _repository = repository;
        }

        /// <summary>
        /// get方法
        /// </summary>
        /// <returns></returns>
        [HttpGet]
        public async Task<ActionResult<List<Student>>> Get()
        {
            return await _repository.GetList();
        }

        /// <summary>
        /// Post方法
        /// </summary>
        /// <param name="entity"></param>
        /// <returns></returns>
        [HttpPost]
        public async Task<bool> Post([FromBody]Student entity)
        {
            int? result = await _repository.Add(entity);
            if(result==null)
            {
                return false;
            }
            else
            {
                return result > 0 ? true : false;
            }
            
        }
    }
}

在增加一個Post的測試方法:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Newtonsoft.Json;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using UnitTest.Model;
using UnitTestDemo;
using Xunit;
using Xunit.Abstractions;

namespace TestDemo
{
    public class StudentTest
    {
        public HttpClient Client { get; }
        public ITestOutputHelper Output { get; }
        public StudentTest(ITestOutputHelper outputHelper)
        {
            var server = new TestServer(WebHost.CreateDefaultBuilder()
           .UseStartup<Startup>());
            Client = server.CreateClient();
            Output = outputHelper;
        }

        [Fact]
        public async Task Get_ShouldBe_Ok()
        {
            // 2、Act
            var response = await Client.GetAsync($"api/student");

            // Output
            string context = await response.Content.ReadAsStringAsync();
            Output.WriteLine(context);
            List<Student> list = JsonConvert.DeserializeObject<List<Student>>(context);

            // Assert
            Assert.Equal(3, list.Count);
        }

        [Fact]
        public async Task Post_ShouldBe_Ok()
        {
            // 1、Arrange
            Student entity = new Student()
            {
             Name="測試9",
             Age=25,
             Gender="男"
            };

            var str = JsonConvert.SerializeObject(entity);
            HttpContent content = new StringContent(str);

            // 2、Act
            content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");

            HttpResponseMessage response = await Client.PostAsync("api/student", content);
            string responseBody = await response.Content.ReadAsStringAsync();
            Output.WriteLine(responseBody);

            // 3、Assert
            Assert.Equal("true", responseBody);
        }
    }
}

執行測試用例:

這樣一個簡單的單元測試就完成了。

我們觀察上面的兩個測試類,發現這兩個類都有一個共同的特點:都是在建構函式裡面建立一個HttpClient物件,我們可以把建立HttpClient物件抽離到一個共同的基礎類別裡面,所有的類都繼承自基礎類別。該基礎類別程式碼如下:

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using System.IO;
using System.Net.Http;
using UnitTestDemo;

namespace TestDemo
{
    /// <summary>
    /// 基礎類別
    /// </summary>
    public class ApiControllerTestBase
    {
        /// <summary>
        /// 返回HttpClient物件
        /// </summary>
        /// <returns></returns>
        protected HttpClient GetClient()
        {
            var builder = new WebHostBuilder()
                                // 指定使用當前目錄
                                .UseContentRoot(Directory.GetCurrentDirectory())
                                // 使用Startup類作為啟動類
                                .UseStartup<Startup>()
                                // 設定使用測試環境
                                .UseEnvironment("Testing");
            var server = new TestServer(builder);
            // 建立HttpClient
            HttpClient client = server.CreateClient();

            return client;
        }
    }
}

然後修改StudentTest類,使該類繼承自上面建立的基礎類別:

using Newtonsoft.Json;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using UnitTest.Model;
using Xunit;
using Xunit.Abstractions;

namespace TestDemo
{
    public class StudentTest: ApiControllerTestBase
    {
        public HttpClient Client { get; }
        public ITestOutputHelper Output { get; }


        public StudentTest(ITestOutputHelper outputHelper)
        {
            // var server = new TestServer(WebHost.CreateDefaultBuilder()
            //.UseStartup<Startup>());
            // Client = server.CreateClient();

            // 從父類別裡面獲取HttpClient物件
            Client = base.GetClient();
            Output = outputHelper;
        }

        [Fact]
        public async Task Get_ShouldBe_Ok()
        {
            // 2、Act
            var response = await Client.GetAsync($"api/student");

            // Output
            string context = await response.Content.ReadAsStringAsync();
            Output.WriteLine(context);
            List<Student> list = JsonConvert.DeserializeObject<List<Student>>(context);

            // Assert
            Assert.Equal(3, list.Count);
        }

        [Fact]
        public async Task Post_ShouldBe_Ok()
        {
            // 1、Arrange
            Student entity = new Student()
            {
             Name="測試9",
             Age=25,
             Gender="男"
            };

            var str = JsonConvert.SerializeObject(entity);
            HttpContent content = new StringContent(str);

            // 2、Act
            content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");

            HttpResponseMessage response = await Client.PostAsync("api/student", content);
            string responseBody = await response.Content.ReadAsStringAsync();
            Output.WriteLine(responseBody);

            // 3、Assert
            Assert.Equal("true", responseBody);
        }
    }
}

文章中的範例程式碼地址:https://github.com/jxl1024/UnitTest

到此這篇關於ASP.NET Core專案使用xUnit進行單元測試的文章就介紹到這了。希望對大家的學習有所幫助,也希望大家多多支援it145.com。


IT145.com E-mail:sddin#qq.com