In last week’s post we generated repositories to access our tables. But do they work, or did we overlook something? Let us find out if everything works as expected by generating integration tests for our repositories.
Limitations and the *.ttincludes files
Be aware of the limitations of the code generators we create in this blog series. The reason for that is the limited space in a blog post and not due to T4.
You can find the code for the _DbAccess.ttinclude and the _Imports.ttinclude in this post.
Challenges of integration tests
Our repositories offer us 4 methods to cover the CRUD operations and we now want a test for each method to make sure they work. As simple as this sound, there are a few challenges we need to address.
Getting meaningful data into our tests is the main problem. Especially when we consider that our generator could create tests for hundreds of classes. If we do not want to drown in an endless dictionary of test data, we need a different approach. We can cheat a little by creating a test data generator for each entity by hand and only call it from the generated integration tests. That way we get all the flexibility and still can generate useful tests.
The other important problem is that we need some data in the database for our Update()
, FindById()
, and Delete()
tests. While we could use a pre-seeded database, we can cheat again and call the Insert()
method in all our tests. That way we always have the right data in place and do not need to first add data to the database. Should our Insert()
method have an error, all our tests will fail. This is a risk worth taking, then if everything fails, we know where to start checking for a problem.
The final obstacle we will run into is cleaning up after our tests. If we skip the housekeeping part, we will have 3 tests per repository that will leave data behind. But since we use a database in our tests, we can use transactions. If we do not commit the changes at the end, then no data will be left behind. We can keep the database in a clean state and still get all the advantages of writing to a database for our tests.
Create the integration tests generator
For our integration test code generator, we create a new template named IntegrationTestGeneratorFromDb.tt, add our *.ttincludes and write our test class inside a loop through our tables:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
<#@ template debug="false" hostspecific="true" language="C#" #> <#@ include file="$(TargetDir)\_Imports.ttinclude" #> <#@ output extension=".txt" #> <#@ include file="$(TargetDir)\T4.FileManager.VisualStudio.ttinclude" #> <#@ include file="$(TargetDir)\_DbAccess.ttinclude" #> <# var schemaReader = new SchemaReader(); var fileManager = new T4FileManager(this); foreach(var itm in schemaReader.ReadTables()) { fileManager.StartNewFile(itm + "RepositoryTests.g.cs","","FromDb/IntegrationTests"); #> using System; using System.Transactions; using Microsoft.Extensions.Configuration; using Microsoft.Data.SqlClient; using FluentAssertions; using GenerateCodeFromDb.Helper; using GenerateCodeFromDb.TestDataGenerators; using NUnit.Framework; using GenerateCodeFromDb.FromDb.Repositories; namespace GenerateCodeFromDb.FromDb.IntegrationTests { [TestFixture] public partial class <#= itm #>RepositoryTests { private I<#= itm #>Repository testee; [OneTimeSetUp] public void OneTimeSetUp() { var connectionString = SettingsReader.ReadSettings().GetConnectionString("db"); this.testee = new <#= itm #>Repository(new SqlConnection(connectionString)); } [Test] public void Create_<#= itm #>() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var <#= itm.ToLower() #> = <#= itm #>Generator.GetTestData(); var newId = this.testee.Create(<#= itm.ToLower() #>); newId.Should().BeGreaterThan(0); } } [Test] public void Find_<#= itm #>() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var <#= itm.ToLower() #> = <#= itm #>Generator.GetTestData(); var newId = this.testee.Create(<#= itm.ToLower() #>); <#= itm.ToLower() #>.Id = newId; var fromDb = this.testee.FindById(newId); fromDb.Should().BeEquivalentTo(<#= itm.ToLower() #>); } } [Test] public void Update_<#= itm #>() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var <#= itm.ToLower() #> = <#= itm #>Generator.GetTestData(); var newId = this.testee.Create(<#= itm.ToLower() #>); var update<#= itm #> = <#= itm #>Generator.GetDataForUpdate(); update<#= itm #>.Id = newId; testee.Update(update<#= itm #>); var fromDb = this.testee.FindById(newId); fromDb.Should().BeEquivalentTo(update<#= itm #>); } } [Test] public void Delete_<#= itm #>() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var <#= itm.ToLower() #> = <#= itm #>Generator.GetTestData(); var newId = this.testee.Create(<#= itm.ToLower() #>); <#= itm.ToLower() #>.Id = newId; testee.Delete(<#= itm.ToLower() #>); var fromDb = this.testee.FindById(newId); fromDb.Should().BeNull(); } } } } <# fileManager.Process(); }; #> |
To reuse the code for reading the connection string, I created this small helper SettingsReader.cs inside the Helper folder:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
using System; using Microsoft.Extensions.Configuration; namespace GenerateCodeFromDb.Helper { public class SettingsReader { public static IConfiguration ReadSettings() { IConfigurationBuilder builder = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", false, true); return (IConfiguration)builder.Build(); } } } |
Run the generator
We can run the custom tool on our generator to create the two integration tests in the FromDb\IntegrationTests folder:
The tests for the CustomerRepository are inside the CustomerRepositoryTests.g.cs class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
using System; using System.Transactions; using Microsoft.Extensions.Configuration; using Microsoft.Data.SqlClient; using FluentAssertions; using GenerateCodeFromDb.Helper; using GenerateCodeFromDb.TestDataGenerators; using NUnit.Framework; using GenerateCodeFromDb.FromDb.Repositories; namespace GenerateCodeFromDb.FromDb.IntegrationTests { [TestFixture] public partial class CustomerRepositoryTests { private ICustomerRepository testee; [OneTimeSetUp] public void OneTimeSetUp() { var connectionString = SettingsReader.ReadSettings().GetConnectionString("db"); this.testee = new CustomerRepository(new SqlConnection(connectionString)); } [Test] public void Create_Customer() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var customer = CustomerGenerator.GetTestData(); var newId = this.testee.Create(customer); newId.Should().BeGreaterThan(0); } } [Test] public void Find_Customer() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var customer = CustomerGenerator.GetTestData(); var newId = this.testee.Create(customer); customer.Id = newId; var fromDb = this.testee.FindById(newId); fromDb.Should().BeEquivalentTo(customer); } } [Test] public void Update_Customer() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var customer = CustomerGenerator.GetTestData(); var newId = this.testee.Create(customer); var updateCustomer = CustomerGenerator.GetDataForUpdate(); updateCustomer.Id = newId; testee.Update(updateCustomer); var fromDb = this.testee.FindById(newId); fromDb.Should().BeEquivalentTo(updateCustomer); } } [Test] public void Delete_Customer() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var customer = CustomerGenerator.GetTestData(); var newId = this.testee.Create(customer); customer.Id = newId; testee.Delete(customer); var fromDb = this.testee.FindById(newId); fromDb.Should().BeNull(); } } } } |
The tests for the ProductRepository are inside the ProductRepositoryTests.g.cs class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
using System; using System.Transactions; using Microsoft.Extensions.Configuration; using Microsoft.Data.SqlClient; using FluentAssertions; using GenerateCodeFromDb.Helper; using GenerateCodeFromDb.TestDataGenerators; using NUnit.Framework; using GenerateCodeFromDb.FromDb.Repositories; namespace GenerateCodeFromDb.FromDb.IntegrationTests { [TestFixture] public partial class ProductRepositoryTests { private IProductRepository testee; [OneTimeSetUp] public void OneTimeSetUp() { var connectionString = SettingsReader.ReadSettings().GetConnectionString("db"); this.testee = new ProductRepository(new SqlConnection(connectionString)); } [Test] public void Create_Product() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var product = ProductGenerator.GetTestData(); var newId = this.testee.Create(product); newId.Should().BeGreaterThan(0); } } [Test] public void Find_Product() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var product = ProductGenerator.GetTestData(); var newId = this.testee.Create(product); product.Id = newId; var fromDb = this.testee.FindById(newId); fromDb.Should().BeEquivalentTo(product); } } [Test] public void Update_Product() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var product = ProductGenerator.GetTestData(); var newId = this.testee.Create(product); var updateProduct = ProductGenerator.GetDataForUpdate(); updateProduct.Id = newId; testee.Update(updateProduct); var fromDb = this.testee.FindById(newId); fromDb.Should().BeEquivalentTo(updateProduct); } } [Test] public void Delete_Product() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var product = ProductGenerator.GetTestData(); var newId = this.testee.Create(product); product.Id = newId; testee.Delete(product); var fromDb = this.testee.FindById(newId); fromDb.Should().BeNull(); } } } } |
The code currently does not compile, then we are missing our two test data generators.
Create the test data generators by hand
We now need to add the test data generators in the TestDataGenerators folder and implement the two methods to return meaningful data.
Our CustomerGenerator.cs class can look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
using System; using GenerateCodeFromDb.FromDb.Entities; namespace GenerateCodeFromDb.TestDataGenerators; public class CustomerGenerator { public static Customer GetTestData() { return new Customer() { LastName = "Example", FirstName = "Joe", CreatedOn = new DateTime(2024, 4, 1, 12, 30, 45), IsActive = true }; } public static Customer GetDataForUpdate() { return new Customer() { LastName = "Updated", FirstName = "Max", CreatedOn = new DateTime(2022, 1, 2, 3, 4, 5), IsActive = false }; } } |
While the ProductGenerator.cs class can have this implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
using System; using GenerateCodeFromDb.FromDb.Entities; namespace GenerateCodeFromDb.TestDataGenerators; public class ProductGenerator { public static Product GetTestData() { return new Product() { Name = "Generator Y", Description = "a basic product", CreatedOn = new DateTime(2024, 4, 1, 5, 6, 8) }; } public static Product GetDataForUpdate() { return new Product() { Name = "Car 2", Description = "a small car", CreatedOn = new DateTime(2023, 1, 2, 3, 4, 5) }; } } |
Run the tests
We can now compile our project and run our tests. They all should pass:
Next
With our newly generated integration tests we know that our repositories work. We only covered the happy path of having valid data, but it only takes a small amount of extra work to test more edge cases.
Next week we explore our options when we need to combine generated with hand-written code.