CSharp 后端服务器如何做到:一边发请求一边看代码覆盖率

作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!


背景

最近在测试一个服务器的时候,遇到一个难题:我如何才能构造出多种请求,以便尽可能的覆盖到所有分支?

写单元测试固然是个办法,但是服务器依赖 mysql 和 redis 等组件,我需要在代码中 mock 这些依赖的组件,单元测试才能独立运行。

有没有办法做到:我发一条请求,就能立即看到代码覆盖率的变化?

下面请看我的表演------

Cooking

all-in-one 调试镜像

我首先制作了一个 DotNet 的 all-in-one 镜像: see, https://github.com/ahfuzhang/CSharpDbgContainer

镜像主要是安装了:

  • DotNet SDK (10/8/6)
  • dotnet tool install dotnet-coverage
  • dotnet tool install dotnet-reportgenerator-globaltool

如果不想自己 build 镜像,可以使用我上传的镜像:

bash 复制代码
docker pull docker.io/ahfuzhang/csharp-dbg-all-in-one:dotnet10

see: https://hub.docker.com/repository/docker/ahfuzhang/csharp-dbg-all-in-one/general

核心代码

代码的原理很简单:

  • dotnet-coverage collect --session-id ${sessionID} cmd: 通过 dotnet-coverage collect 来启动程序,并且创建一个 session id
  • dotnet-coverage snapshot --output ${coverageFile} ${sessionID}: 运行期通过 session id 采集 coverage 信息到一个文件
  • dotnet-coverage merge {coverageFile} --output {coberturaFile} --output-format cobertura: 把 coverage 文件转换为 xml 格式
  • reportgenerator -reports:{coberturaFile} -targetdir:{htmlDir} -reporttypes:Html: 把 xml 文件渲染为 html
csharp 复制代码
   public static async Task CodeCoverageCallbackAsync(HttpContext ctx)
    {
        var sessionId = ctx.Request.Query["session-id"].FirstOrDefault();
        if (string.IsNullOrEmpty(sessionId))
        {
            ctx.Response.StatusCode = 400;
            await ctx.Response.WriteAsync(HtmlError("Missing query parameter: session-id"));
            return;
        }

        var uuid = Guid.NewGuid().ToString("N");
        var coverageFile = $"/tmp/{uuid}.coverage";
        var coberturaFile = $"/tmp/{uuid}.cobertura.xml";
        var htmlDir = $"/tmp/html_{uuid}/";

        // Step 1: snapshot
        var (snapshotOk, snapshotErr) = await RunCommandAsync(
            "dotnet-coverage",
            $"snapshot --output {coverageFile} {sessionId}");
        if (!snapshotOk)
        {
            ctx.Response.StatusCode = 500;
            ctx.Response.ContentType = "text/html; charset=utf-8";
            await ctx.Response.WriteAsync(HtmlError($"dotnet-coverage snapshot failed:\n{snapshotErr}"));
            return;
        }

        // Step 2: convert to cobertura xml
        var (mergeOk, mergeErr) = await RunCommandAsync(
            "dotnet-coverage",
            $"merge {coverageFile} --output {coberturaFile} --output-format cobertura");
        if (!mergeOk)
        {
            ctx.Response.StatusCode = 500;
            ctx.Response.ContentType = "text/html; charset=utf-8";
            await ctx.Response.WriteAsync(HtmlError($"dotnet-coverage merge failed:\n{mergeErr}"));
            return;
        }

        // Step 3: generate html report
        var (reportOk, reportErr) = await RunCommandAsync(
            "reportgenerator",
            $"-reports:{coberturaFile} -targetdir:{htmlDir} -reporttypes:Html");
        if (!reportOk)
        {
            ctx.Response.StatusCode = 500;
            ctx.Response.ContentType = "text/html; charset=utf-8";
            await ctx.Response.WriteAsync(HtmlError($"reportgenerator failed:\n{reportErr}"));
            return;
        }

        ctx.Response.Redirect($"/code_coverage_report/{uuid}/");
    }

具体代码请看:https://github.com/ahfuzhang/QiWa.DemoServer/blob/main/src/CodeCoverage/CodeCoverage.cs

编译 debug 版本的 dll

bash 复制代码
dotnet publish $(PRJ).csproj \
	  -r linux-x64 \
	  -p:DefineConstants=UNIX -p:AllowUnsafeBlocks=true \
	  -p:StripSymbols=false \
	  -p:InvariantGlobalization=true \
	  -p:EventSourceSupport=true \
	  -p:EmbedAllSources=true \
	  -p:DebugType=portable \
	  -p:DebugSymbols=true \
	  -p:Optimize=false \
	  -p:CopyOutputSymbolsToPublishDirectory=true \
	  -p:TieredCompilation=false \
	  --self-contained false \
	  -c Debug -o $(BUILD_DIR)

特别要注意,不要加这些选项:

  • PublishAot=true : 触发 AOT 原生编译的核心标志
  • StaticLinkedRuntime=true / StaticExecutable=true --- 静态链接原生可执行文件
  • PositionIndependentExecutable=true --- 仅对原生二进制有意义
  • --self-contained true: 输出产物将为托管 DLL,依赖系统安装的 .NET 运行时。

为了保障工具集的版本一致性,也可以使用 docker 中的命令来编译:

bash 复制代码
docker run --rm \
		-v ./:/app \
		-w /app \
		--user "$(id -u):$(id -g)" \
		ahfuzhang/csharp-dbg-all-in-one:dotnet10 \
		dotnet publish $(PRJ).csproj \
			-r linux-x64 \
			-p:DefineConstants=UNIX -p:AllowUnsafeBlocks=true \
			-p:StripSymbols=false \
			-p:InvariantGlobalization=true \
			-p:EventSourceSupport=true \
			-p:EmbedAllSources=true \
			-p:DebugType=portable \
			-p:DebugSymbols=true \
			-p:Optimize=false \
			-p:CopyOutputSymbolsToPublishDirectory=true \
			-p:TieredCompilation=false \
			--self-contained false \
			-c Debug -o $(BUILD_DIR)

在 all-in-one 镜像中启动服务器

bash 复制代码
docker run --rm \
		-v ./:/app \
		-w /app \
		--user "$(id -u):$(id -g)" \
		--network host \
		ahfuzhang/csharp-dbg-all-in-one:dotnet10 \
		dotnet-coverage collect \
			--session-id my-server-cov \
			--output /tmp/demo_server.coverage \
			dotnet $(BUILD_DIR)/QiWa.DemoServer.dll -- \
				-log.level=debug

注意:

  • 要使用 dotnet-coverage collect 来启动
  • --session-id my-server-cov 这个参数指定了 session id 的名字叫做 my-server-cov,要记住这个名字
  • dotnet xx.dll 保障了程序使用 dotnet CLR 。如果静态编译的话,可能就无法采集了
  • -- 这个后面是服务器的命令行参数

通过浏览器查看代码覆盖情况

打开浏览器访问:

复制代码
http://127.0.0.1:8091/code_coverage?session-id=my-server-cov
  • 注意:要带上 my-server-cov 这个名字

会触发后端运行命令,并生成 html 报告:

每次构造请求后,我都用浏览器访问一次 /code_coverage,然后可以看见代码的覆盖率在提升。

总结

在线实时查看代码覆盖率,能够很好的解决老项目难以写可 mock 的单元测试的难题。

Have Fun. 😃