前言
gRPC憑藉其嚴謹的介面定義、高效的傳輸效率、多樣的呼叫方式等優點,在微服務開發方面佔據了一席之地。dotnet core正式支援gRPC也有一段時間了,官方文件也對如何使用gRPC進行了比較詳細的說明,但是關於如何對gRPC的伺服器和客戶端進行單元測試,卻沒有描述。經過查閱官方程式碼,找到了一些解決方法,總結在此,供大家參考。
本文重點介紹gRPC伺服器端程式碼的單元測試,包括普通呼叫、伺服器端流、客戶端流等呼叫方式的單元測試,另外,引入sqlite的記憶體資料庫模式,對資料庫相關操作進行測試。
準備gRPC服務端專案
使用dotnet new grpc命令建立一個gRPC伺服器專案。
修改protos/greeter.proto, 新增兩個介面方法:
//伺服器流 rpc SayHellos (HelloRequest) returns (stream HelloReply); //客戶端流 rpc Sum (stream HelloRequest) returns (HelloReply);
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Grpc.Core; using GrpcTest.Server.Models; using Microsoft.Extensions.Logging; namespace GrpcTest.Server { public class GreeterService : Greeter.GreeterBase { private readonly ILogger<GreeterService> _logger; private readonly ApplicationDbContext _db; public GreeterService(ILogger<GreeterService> logger, ApplicationDbContext db) { _logger = logger; _db = db; } public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context) { return Task.FromResult(new HelloReply { Message = "Hello " + request.Name }); } public override async Task SayHellos(HelloRequest request, IServerStreamWriter<HelloReply> responseStream, ServerCallContext context) { foreach (var student in _db.Students) { if (context.CancellationToken.IsCancellationRequested) break; var message = student.Name; _logger.LogInformation($"Sending greeting {message}."); await responseStream.WriteAsync(new HelloReply { Message = message }); } } public override async Task<HelloReply> Sum(IAsyncStreamReader<HelloRequest> requestStream, ServerCallContext context) { var sum = 0; await foreach (var request in requestStream.ReadAllAsync()) { if (int.TryParse(request.Name, out var number)) sum += number; else throw new ArgumentException("引數必須是可識別的數字"); } return new HelloReply { Message = $"sum is {sum}" }; } } }
SayHello: 簡單的返回一個文字訊息。
SayHellos: 從資料庫的表中讀取所有資料,並且使用伺服器端流的方式返回。
Sum:從客戶端流獲取輸入資料,並計算所有資料的和,如果輸入的文字無法轉換為數字,丟擲異常。
單元測試
新建xunit專案,並引用剛才建立的gRPC專案,引入如下包:
<ItemGroup> <PackageReference Include="Grpc.Core.Testing" Version="2.28.1" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" /> <PackageReference Include="moq" Version="4.14.1" /> <PackageReference Include="xunit" Version="2.4.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" /> <PackageReference Include="coverlet.collector" Version="1.2.0" /> </ItemGroup>
偽造Logger
使用sqlite inmemory的DbContext
public static ApplicationDbContext CreateDbContext(){ var db = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>() .UseSqlite(CreateInMemoryDatabase()).Options); db.Database.EnsureCreated(); return db; } private static DbConnection CreateInMemoryDatabase() { var connection = new SqliteConnection("Filename=:memory:"); connection.Open(); return connection; }
重點:雖然是記憶體模式,資料庫也必須是open的,並且需要執行EnsureCreated,否則呼叫資料庫功能是會報告找不到表。
偽造ServerCallContext
使用如下程式碼偽造:
public static ServerCallContext CreateTestContext(){ return TestServerCallContext.Create("fooMethod", null, DateTime.UtcNow.AddHours(1), new Metadata(), CancellationToken.None, "127.0.0.1", null, null, (metadata) => TaskUtils.CompletedTask, () => new WriteOptions(), (writeOptions) => { }); }
裡面的具體引數要依據實際測試需要進行調整,比如測試客戶端取消操作時,修改CancellationToken引數。
普通呼叫的測試
[Fact] public void SayHello() { var service = new GreeterService(logger, null); var request = new HelloRequest{Name="world"}; var response = service.SayHello(request, scc).Result; var expected = "Hello world"; var actual = response.Message; Assert.Equal(expected, actual); }
其中scc = 偽造的ServerCallContext,如果被測方法中沒有實際使用它,也可以直接傳入null。
伺服器端流的測試
伺服器端流的方法包含一個IServerStreamWriter<HelloReply>型別的引數,該引數被用於將方法的計算結果逐個返回給呼叫方,可以建立一個通用的類實現此介面,將寫入的訊息儲存為一個list,以便測試。
public class TestServerStreamWriter<T> : IServerStreamWriter<T> { public WriteOptions WriteOptions { get; set; } public List<T> Responses { get; } = new List<T>(); public Task WriteAsync(T message) { this.Responses.Add(message); return Task.CompletedTask; } }
測試時,向資料庫表中插入兩條記錄,然後測試對比,看介面方法是否返回兩條記錄。
public async Task SayHellos(){ var db = TestTools.CreateDbContext(); var students = new List<Student>{ new Student{Name="1"}, new Student{Name="2"} }; db.AddRange(students); db.SaveChanges(); var service = new GreeterService(logger, db); var request = new HelloRequest{Name="world"}; var sw = new TestServerStreamWriter<HelloReply>(); await service.SayHellos(request, sw, scc); var expected = students.Count; var actual = sw.Responses.Count; Assert.Equal(expected, actual); }
客戶端流的測試
與伺服器流類似,客戶端流方法也有一個引數型別為IAsyncStreamReader<HelloRequest>,簡單實現一個類用於測試。
該類通過直接將客戶端要傳入的資料通過IEnumable<T>引數傳入,模擬客戶端的流式請求多個資料。
public class TestStreamReader<T> : IAsyncStreamReader<T> { private readonly IEnumerator<T> _stream; public TestStreamReader(IEnumerable<T> list){ _stream = list.GetEnumerator(); } public T Current => _stream.Current; public Task<bool> MoveNext(CancellationToken cancellationToken) { return Task.FromResult(_stream.MoveNext()); } }
正常流程測試程式碼
[Fact] public void Sum_NormalInput_ReturnSum() { var service = new GreeterService(null, null); var data = new List<HelloRequest>{ new HelloRequest{Name="1"}, new HelloRequest{Name="2"}, }; var stream = new TestStreamReader<HelloRequest>(data); var response = service.Sum(stream, scc).Result; var expected = "sum is 3"; var actual = response.Message; Assert.Equal(expected, actual); }
引數錯誤的測試程式碼
[Fact] public void Sum_BadInput_ThrowException() { var service = new GreeterService(null, null); var data = new List<HelloRequest>{ new HelloRequest{Name="1"}, new HelloRequest{Name="abc"}, }; var stream = new TestStreamReader<HelloRequest>(data); Assert.ThrowsAsync<ArgumentException>(async () => await service.Sum(stream, scc)); }
總結
以上程式碼,通過對gRPC服務依賴的關鍵資源進行mock或簡單實現,達到了單元測試的目的。