Code coverage reports uncovered branch on method returning IAsyncEnumerable

2 weeks ago 12
ARTICLE AD BOX

I have the following function, which is basically a wrapper around something that makes a SQL call and reshapes the results:

public async IAsyncEnumerable<T> ExecuteTable<T>(string sql, IDictionary<string, object?>? parameters, [EnumeratorCancellation] CancellationToken cancellationToken) { var res = await ExecuteSQL(sql, parameters, cancellationToken).ConfigureAwait(false); foreach (var row in res.Rows) { cancellationToken.ThrowIfCancellationRequested(); yield return RowConverter.ConvertRow<T>(res.Columns, row); } }

You might notice that the enumeration is not really async, but this is an implementation of an interface, and it has to return IAsyncEnumerable. ExecuteSQL is truly async, but it sends the query elsewhere, and then returns the result in a single batch (in a network-friendly shape that has to be reshaped by ConvertRow).

I have unit tests covering this. The tests do this:

Successful execution, with results.

Successful execution, with no results.

Cancellation in ExecuteSQL (which throws OperationCanceledException).

Cancellation in the loop.

Non-cancellation exception in ExecuteSQL.

This is the question: the code coverage tool reports a branch not covered, on the very last closed bracket (the one that ends the method). Why?

Of course, this isn't a big deal; I would be okay with ignoring the issue - except that there is another method, which is absolutely identical except that it's not generic and returns IAsyncEnumerable<dynamic> instead (with a RowConverter.CovertDynamic call instead of RowConverter.ConvertRow<T>). That other method is exerted by unit tests in exactly the same way, and code coverage reports it as fully covered!

So, I'm curious as to what the difference could possibly be - why the compiler is apparently producing an unreachable branch, but only for generic and not for dynamic.

I've even tried debugging into the IL, but it's too difficult to follow. I've also created a non-generic and non-dynamic version just to see what happens; it reports as fully covered.

For reference, this is using xUnit and coverlet with Fine Code Coverage in VS.

Full unit test code follows. The actual network calls are mocked. The point of these tests is specifically to validate the wrapper. Yes, there are way too many tests for this; this is specifically because I want to find out what's going on. I would not normally write this many tests for a five lines function.

[Fact] public async Task ExecuteTable_CallsWrapperAndReturnsRecords() { // Arrange QueryRequest? capturedRequest = null; QueryResponse response = new(); response.Columns.Add(new Column() { Name = "ID", Type = RPCType.Int32 }); response.Columns.Add(new Column() { Name = "Name", Type = RPCType.String }); response.Columns.Add(new Column() { Name = "Value", Type = RPCType.Double }); for (int i = 1; i <= 3; i++) { var row = new Row(); row.Values.Add(new RPCValue() { IntValue = i }); row.Values.Add(new RPCValue() { StringValue = $"Test{i}" }); row.Values.Add(new RPCValue() { DoubleValue = 3.14 * i }); response.Rows.Add(row); } _databaseClientWrapperMock.ExecuteQuery(Arg.Do<QueryRequest>(request => capturedRequest = request), Arg.Any<CancellationToken>()).Returns(response); var client = CreateDatabaseClient(); // Act var results = new List<TestRecord>(); await foreach (var record in client.ExecuteTable<TestRecord>("SELECT * FROM Table", null, TestContext.Current.CancellationToken)) { results.Add(record); } // Assert Assert.NotNull(capturedRequest); Assert.Equal("SELECT * FROM Table", capturedRequest.Sql); Assert.Empty(capturedRequest.Parameters); Assert.Equal(3, results.Count); for (int i = 0; i < 3; i++) { Assert.Equal(i + 1, results[i].ID); Assert.Equal($"Test{i + 1}", results[i].Name); Assert.Equal(3.14 * (i + 1), results[i].Value); } } [Fact] public async Task ExecuteTable_NoResults() { // Arrange QueryRequest? capturedRequest = null; QueryResponse response = new(); response.Columns.Add(new Column() { Name = "ID", Type = RPCType.Int32 }); response.Columns.Add(new Column() { Name = "Name", Type = RPCType.String }); response.Columns.Add(new Column() { Name = "Value", Type = RPCType.Double }); _databaseClientWrapperMock.ExecuteQuery(Arg.Do<QueryRequest>(request => capturedRequest = request), Arg.Any<CancellationToken>()).Returns(response); var client = CreateDatabaseClient(); // Act var results = new List<TestRecord>(); await foreach (var record in client.ExecuteTable<TestRecord>("SELECT * FROM Table", null, TestContext.Current.CancellationToken)) results.Add(record); // Assert Assert.NotNull(capturedRequest); Assert.Equal("SELECT * FROM Table", capturedRequest.Sql); Assert.Empty(capturedRequest.Parameters); Assert.Empty(results); } [Fact] public async Task ExecuteTable_Exception() { // Arrange QueryRequest? capturedRequest = null; QueryResponse response = new(); response.Columns.Add(new Column() { Name = "ID", Type = RPCType.Int32 }); response.Columns.Add(new Column() { Name = "Name", Type = RPCType.String }); response.Columns.Add(new Column() { Name = "Value", Type = RPCType.Double }); await _databaseClientWrapperMock.ExecuteQuery(Arg.Do<QueryRequest>(request => { capturedRequest = request; throw new Exception(); }), Arg.Any<CancellationToken>()); var client = CreateDatabaseClient(); // Act & Assert var results = new List<TestRecord>(); await Assert.ThrowsAsync<Exception>(async () => { await foreach (var record in client.ExecuteTable<TestRecord>("SELECT * FROM Table", null, TestContext.Current.CancellationToken)) results.Add(record); }); // Assert Assert.NotNull(capturedRequest); Assert.Equal("SELECT * FROM Table", capturedRequest.Sql); Assert.Empty(capturedRequest.Parameters); Assert.Empty(results); } [Fact] public async Task ExecuteTable_SlowExecution() { // Arrange QueryRequest? capturedRequest = null; QueryResponse response = new(); response.Columns.Add(new Column() { Name = "ID", Type = RPCType.Int32 }); response.Columns.Add(new Column() { Name = "Name", Type = RPCType.String }); response.Columns.Add(new Column() { Name = "Value", Type = RPCType.Double }); var tcs = new TaskCompletionSource(); _databaseClientWrapperMock.ExecuteQuery(Arg.Do<QueryRequest>(request => capturedRequest = request), Arg.Any<CancellationToken>()).Returns(async _ => { await tcs.Task.WaitAsync(TestContext.Current.CancellationToken); await Task.Delay(100); return response; }); var client = CreateDatabaseClient(); // Act var results = new List<TestRecord>(); var enumeration = client.ExecuteTable<TestRecord>("SELECT * FROM Table", null, TestContext.Current.CancellationToken); await using var enumerator = enumeration.GetAsyncEnumerator(TestContext.Current.CancellationToken); tcs.SetResult(); while (await enumerator.MoveNextAsync()) results.Add(enumerator.Current); // Assert Assert.NotNull(capturedRequest); Assert.Equal("SELECT * FROM Table", capturedRequest.Sql); Assert.Empty(capturedRequest.Parameters); Assert.Empty(results); } [Fact] public async Task ExecuteTable_CancelImmediately() { // Arrange QueryRequest? capturedRequest = null; QueryResponse response = new(); response.Columns.Add(new Column() { Name = "ID", Type = RPCType.Int32 }); response.Columns.Add(new Column() { Name = "Name", Type = RPCType.String }); response.Columns.Add(new Column() { Name = "Value", Type = RPCType.Double }); _databaseClientWrapperMock.ExecuteQuery(Arg.Do<QueryRequest>(request => capturedRequest = request), Arg.Any<CancellationToken>()).Returns(response); var client = CreateDatabaseClient(); // Act & Assert var results = new List<TestRecord>(); var cts = new CancellationTokenSource(); cts.Cancel(); await Assert.ThrowsAsync<OperationCanceledException>(async () => { await client.ExecuteTable<TestRecord>("SELECT * FROM Table", null, cts.Token).FirstAsync(cts.Token); }); } [Fact] public async Task ExecuteTable_CancelEnumeration() { // Arrange QueryRequest? capturedRequest = null; QueryResponse response = new(); response.Columns.Add(new Column() { Name = "ID", Type = RPCType.Int32 }); response.Columns.Add(new Column() { Name = "Name", Type = RPCType.String }); response.Columns.Add(new Column() { Name = "Value", Type = RPCType.Double }); for (int i = 1; i <= 3; i++) { var row = new Row(); row.Values.Add(new RPCValue() { IntValue = i }); row.Values.Add(new RPCValue() { StringValue = $"Test{i}" }); row.Values.Add(new RPCValue() { DoubleValue = 3.14 * i }); response.Rows.Add(row); } _databaseClientWrapperMock.ExecuteQuery(Arg.Do<QueryRequest>(request => capturedRequest = request), Arg.Any<CancellationToken>()).Returns(response); var client = CreateDatabaseClient(); var cts = new CancellationTokenSource(); // Act var enumerable = client.ExecuteTable<TestRecord>("SELECT * FROM Table", null, TestContext.Current.CancellationToken); await using var enumerator = enumerable.GetAsyncEnumerator(cts.Token); bool hasFirstRecord = await enumerator.MoveNextAsync(); var firstRecord = enumerator.Current; cts.Cancel(); await Assert.ThrowsAsync<OperationCanceledException>(async () => await enumerator.MoveNextAsync()); // Assert Assert.NotNull(capturedRequest); Assert.Equal("SELECT * FROM Table", capturedRequest.Sql); Assert.Empty(capturedRequest.Parameters); Assert.True(hasFirstRecord); Assert.NotNull(firstRecord); Assert.Equal(1, firstRecord.ID); Assert.Equal("Test1", firstRecord.Name); Assert.Equal(3.14, firstRecord.Value); }

Edit: here's the coverage report for ExecuteTable and ExecuteDynamic. You'll notice that ExecuteTable has a branch on line 63 (which is the closing bracket). The closing bracket on ExecuteDynamic is on line 73, and does not have a branch! Why would the compiler emit a branch on the closing bracket of ExecuteTable, but not on ExecuteDynamic?

<class name="Magma.Database.Client.DatabaseClient/&lt;ExecuteTable&gt;d__9`1" filename="Magma.Database.Client\DatabaseClient.cs" line-rate="1" branch-rate="0.8332999999999999" complexity="6"> <methods> <method name="MoveNext" signature="()" line-rate="1" branch-rate="0.8332999999999999" complexity="6"> <lines> <line number="56" hits="10" branch="False" /> <line number="57" hits="10" branch="False" /> <line number="58" hits="42" branch="True" condition-coverage="100% (4/4)"> <conditions> <condition number="380" type="jump" coverage="100%" /> <condition number="472" type="jump" coverage="100%" /> </conditions> </line> <line number="59" hits="10" branch="False" /> <line number="60" hits="10" branch="False" /> <line number="61" hits="8" branch="False" /> <line number="62" hits="8" branch="False" /> <line number="63" hits="6" branch="True" condition-coverage="50% (1/2)"> <conditions> <condition number="575" type="jump" coverage="50%" /> </conditions> </line> </lines> </method> </methods> <lines> <line number="56" hits="10" branch="False" /> <line number="57" hits="10" branch="False" /> <line number="58" hits="42" branch="True" condition-coverage="100% (4/4)"> <conditions> <condition number="380" type="jump" coverage="100%" /> <condition number="472" type="jump" coverage="100%" /> </conditions> </line> <line number="59" hits="10" branch="False" /> <line number="60" hits="10" branch="False" /> <line number="61" hits="8" branch="False" /> <line number="62" hits="8" branch="False" /> <line number="63" hits="6" branch="True" condition-coverage="50% (1/2)"> <conditions> <condition number="575" type="jump" coverage="50%" /> </conditions> </line> </lines> </class> <class name="Magma.Database.Client.DatabaseClient/&lt;ExecuteDynamic&gt;d__10" filename="Magma.Database.Client\DatabaseClient.cs" line-rate="1" branch-rate="1" complexity="2"> <methods> <method name="MoveNext" signature="()" line-rate="1" branch-rate="1" complexity="2"> <lines> <line number="66" hits="4" branch="False" /> <line number="67" hits="4" branch="False" /> <line number="68" hits="26" branch="True" condition-coverage="100% (2/2)"> <conditions> <condition number="375" type="jump" coverage="100%" /> </conditions> </line> <line number="69" hits="8" branch="False" /> <line number="70" hits="8" branch="False" /> <line number="71" hits="6" branch="False" /> <line number="72" hits="6" branch="False" /> <line number="73" hits="2" branch="False" /> </lines> </method> </methods> <lines> <line number="66" hits="4" branch="False" /> <line number="67" hits="4" branch="False" /> <line number="68" hits="26" branch="True" condition-coverage="100% (2/2)"> <conditions> <condition number="375" type="jump" coverage="100%" /> </conditions> </line> <line number="69" hits="8" branch="False" /> <line number="70" hits="8" branch="False" /> <line number="71" hits="6" branch="False" /> <line number="72" hits="6" branch="False" /> <line number="73" hits="2" branch="False" /> </lines> </class>
Read Entire Article