This page explains the testing strategy in place for Thrift.Net. Our strategy is driven by some of the key goals of the project:
- The ability to release new functionality and fixes as soon as they are ready.
- Welcoming contributions from anyone who wants to help with the project.
In order to achieve these goals while being confident that any changes don't break existing functionality, and that we maintain a high level of quality, we need to have an automated testing strategy in place.
To achieve this we use a combination of different types of testing:
All of our unit tests can be found in the Thrift.Net.Tests project. Within this project, the tests are arranged based on the project that they are testing. For example:
- Compilation has tests for the Thrift.Net.Compilation project.
- Compiler has tests for the Thrift.Net.Compiler project.
We use the following tools for unit testing:
- xUnit.net as our unit test framework.
- NSubstitute for mocking/substituting dependencies.
- Coverlet for code coverage.
The following sections provide more information about our unit testing guidelines:
- AAA
- One Test - One Assertion
- Code Coverage
- Testing Language Features
- Testing Compiler Message
- XUnit Theories
Tests should typically follow the Arrange-Act-Assert (AAA) pattern. This is designed to keep tests simple and focused by splitting them into three separate sections:
[Fact]
public void DoesSomething()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(1, 2);
// Assert
Assert.Equal(3, result);
}As shown above, the Arrange section performs any setup required for the test, the Act section performs the action we want to test, and the Assert section performs any assertions.
Our tests use the following naming scheme:
<ItemBeingTested>_<Scenario>_<Expectation>
For example, if we had a test for the StructBinder that ensured a struct's
name was set if it was supplied, it might be called
Bind_NameSupplied_SetsName().
Sometimes we want to split tests up to avoid a single test class becoming unmanageable. For example, we might end up with a structure like this:
EnumMemberBinder
--> GetEnumValueTests.cs
--> IsValueImplicitTests.cs
--> NameTests.cs
--> ValueTests.cs
In this case, we can drop the ItemBeingTested part of the name, because it's
implied by the filename. So some example test names in GetEnumValueTests are:
NodeIsOnlyMember_ReturnsZero().HasPreviousSibling_ReturnsNextValue().PreviousSiblingHasCustomValue_ReturnsNextValue().
In general, a single test method should have a single assertion. If you need to check multiple things, it probably means the test needs to be split up into multiple tests.
// Good: we have two separate tests to test separate scenarios
[Fact]
public void Compile_DocumentContainsStruct_AddsStructToModel()
{
// Arrange
var input = "struct User {}";
// Act
var result = this.compiler.Compile(input.ToStream());
// Assert
Assert.Collection(
result.Document.Structs,
item => Assert.Equal("User", item.Name));
}
[Fact]
public void Compile_StructContainsFields_AddsFieldsToStruct()
{
// Arrange
var input =
@"struct User {
i32 Id
string Name
}";
// Act
var result = this.compiler.Compile(input.ToStream());
// Assert
var definition = result.Document.Structs.First();
Assert.Collection(
definition.Fields,
item => Assert.Equal("Id", item.Name),
item => Assert.Equal("Name", item.Name));
}
// Bad: we're trying to test at least three different things in a single test
[Fact]
public void Compile_DocumentContainsStruct_ParsesStruct()
{
// Arrange
var input =
@"struct User {
1: i32 Id
5: string Name
}";
// Act
var result = this.compiler.Compile(input.ToStream());
// Assert
var definition = result.Document.Structs.First();
// 1. We're testing the struct name is parsed correctly here
Assert.Equal("User", definition.Name);
Assert.Collection(
definition.Fields,
item =>
{
// 2. We're testing field Ids are parsed correctly here
Assert.Equal(1, item.FieldId);
// 3. We're testing field names are parsed correctly here
Assert.Equal("Id", item.Name);
},
item =>
{
Assert.Equal(5, item.FieldId);
Assert.Equal("Name", item.Name);
});
}At the same time, there are definitely situations where this doesn't make sense, so this is meant as a guide rather than a hard and fast rule.
We generate code coverage reports during the build process in Azure Pipelines. The build is also configured to fail if the coverage drops below a certain threshold. This isn't intended to force us to artificially add tests that don't make sense, but is there as a safety net so that we need to make a conscious decision about not testing certain components.
If a PR is added that causes the coverage to drop below this threshold, we should have a conversation about whether we just need to add tests to increase the coverage, or whether it makes sense to adjust the coverage settings.
If we decide to adjust the settings, we have the following options:
- Lowering the coverage threshold.
- Adjusting the coverage settings to exclude certain parts of the code from analysis.
In general we'll want to avoid adjusting the threshold, and instead should focus on excluding any code that should not or cannot be tested. This is configured via the Thrift.Net.Tests.runsettings file.
To provide a concrete example of this, the Thrift.Net.Antlr assembly is
excluded from coverage via the following pattern:
[Thrift.Net.Antlr]*
The reason that this assembly is excluded is because it only contains generated code, and we don't use all of the functionality that Antlr generates. This ends up skewing our code coverage stats, and attempting to add tests for the unused functionality would be pointless, and would also result in us testing 3rd party library code.
To test that the compiler parses a piece of Thrift code correctly, you can create a new instance of the compiler, parse an input string, and then verify that the correct results are produced:
// Arrange
var compiler = new ThriftCompiler();
var input = "struct User {}";
// Act
var result = compiler.Compile(input.ToStream());
// Assert
Assert.Collection(
result.Document.Structs,
item => Assert.Equal("User", item.Name));When testing that the compiler produces the correct messages, we want to check a few things:
- The message Id and message text are correct.
- The message level is correct (for example Error or Warning).
- The line and character positions are correct.
The ThriftCompilerTests class has a number of helper methods like
AssertCompilerReturnsErrorMessage() to help with this. Here's an example
showing how to check the compiler returns an error if an enum member is missing
its name:
[Fact]
public void Compile_EnumMemberNameMissing_ReportsError()
{
this.AssertCompilerReturnsErrorMessage(
@"enum UserType {
User = 0,
$= 1$
}",
CompilerMessageId.EnumMemberMustHaveAName);
}As you can see, part of the input text is surrounded by $ signs ($= 1$).
This allows the test to automatically detect the line and column range where the
error message should be reported.
There are some situations where we want to perform the same test against a range
of input values. To do this we can use XUnit Theories. An example of this is
where we want to ensure that the BaseTypeBinder can bind all the Thrift base
types:
[Theory]
[InlineData("bool")]
[InlineData("byte")]
[InlineData("i8")]
[InlineData("i16")]
[InlineData("i32")]
[InlineData("i64")]
[InlineData("double")]
[InlineData("string")]
[InlineData("binary")]
[InlineData("slist")]
public void CanBindAllBaseTypes(string baseType)
{
// Arrange
var binder = new BaseTypeBinder();
var fieldTypeNode = ParserInput
.FromString(baseType)
.ParseInput(parser => parser.baseType());
// Act
var parent = Substitute.For<IField>();
var type = binder.Bind<BaseType>(fieldTypeNode, parent);
// Assert
Assert.Equal(baseType, type.Name);
}Although unit tests cover a lot of our needs, there are certain scenarios that we either can't test via unit tests, or where a unit testing approach is inefficient or difficult. This includes:
- Testing parts of the code generation process that rely on compiling and running the generated code.
- Integration testing to ensure that Thrift.Net clients and servers can communicate correctly.
- Integration testing to ensure that Thrift.Net can communicate with other Thrift implementations.
We will update this section with more detail once the Thrift compiler is completed, and we have the basic functionality in place to support creating integration tests.