From a03bd980a972072db259595d0ba7a0bc3aabf18c Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 23 Apr 2020 16:11:36 -0700 Subject: [PATCH] Testing with different database providers More docs and samples Fixes #430 Fixes #1304 Fixes #1724 Fixes #1929 Fixes #1929 Fixes #2236 Fixes #2262 Fixes #2301 --- .../core/miscellaneous/testing/in-memory.md | 71 ++---- .../core/miscellaneous/testing/index.md | 29 ++- .../testing/sharing-databases.md | 103 ++++++++ .../core/miscellaneous/testing/sqlite.md | 61 +++-- .../miscellaneous/testing/testing-sample.md | 223 ++++++++++++++++++ entity-framework/toc.yml | 8 +- .../Controllers/ItemsController.cs | 77 ++++++ .../Testing/ItemsWebApi/ItemsWebApi/Item.cs | 44 ++++ .../ItemsWebApi/ItemsWebApi/ItemsContext.cs | 36 +++ .../ItemsWebApi/ItemsWebApi.csproj | 12 + .../ItemsWebApi/ItemsWebApi/Program.cs | 17 ++ .../ItemsWebApi/ItemsWebApi/Startup.cs | 42 ++++ .../Testing/ItemsWebApi/ItemsWebApi/Tag.cs | 21 ++ .../ItemsWebApi/appsettings.Development.json | 9 + .../ItemsWebApi/ItemsWebApi/appsettings.json | 10 + .../SharedDatabaseFixture.cs | 76 ++++++ .../SharedDatabaseTests/SharedDatabaseTest.cs | 157 ++++++++++++ .../SharedDatabaseTests.csproj | 18 ++ .../Tests/InMemoryItemsControllerTest.cs | 16 ++ .../ItemsWebApi/Tests/ItemsControllerTest.cs | 198 ++++++++++++++++ .../Tests/SqlServerItemsControllerTest.cs | 16 ++ .../SqliteInMemoryItemsControllerTest.cs | 36 +++ .../Tests/SqliteItemsControllerTest.cs | 18 ++ .../Testing/ItemsWebApi/Tests/Tests.csproj | 20 ++ samples/core/Samples.sln | 60 +++++ 25 files changed, 1275 insertions(+), 103 deletions(-) create mode 100644 entity-framework/core/miscellaneous/testing/sharing-databases.md create mode 100644 entity-framework/core/miscellaneous/testing/testing-sample.md create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Item.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsContext.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsWebApi.csproj create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Program.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Startup.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Tag.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.Development.json create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.json create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseFixture.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTest.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTests.csproj create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/InMemoryItemsControllerTest.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqlServerItemsControllerTest.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteInMemoryItemsControllerTest.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteItemsControllerTest.cs create mode 100644 samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/Tests.csproj diff --git a/entity-framework/core/miscellaneous/testing/in-memory.md b/entity-framework/core/miscellaneous/testing/in-memory.md index cefd44e519..ab30766ae3 100644 --- a/entity-framework/core/miscellaneous/testing/in-memory.md +++ b/entity-framework/core/miscellaneous/testing/in-memory.md @@ -1,64 +1,23 @@ --- -title: Testing with InMemory - EF Core -author: rowanmiller +title: Testing with the EF In-Memory Database - EF Core +author: ajcvickers +description: Using the EF in-memory database to test an EF Core application ms.date: 10/27/2016 -ms.assetid: 0d0590f1-1ea3-4d5c-8f44-db17395cd3f3 uid: core/miscellaneous/testing/in-memory --- -# Testing with InMemory +# Testing with the EF In-Memory Database -The InMemory provider is useful when you want to test components using something that approximates connecting to the real database, without the overhead of actual database operations. +> [!WARNING] +> The EF in-memory database often behaves differently than relational databases. +> Only use the EF in-memory database after fully understanding the issues and trade-offs involved, as discussed in [Testing code that uses EF Core](xref:core/miscellaneous/testing/index). -> [!TIP] -> You can view this article's [sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/Miscellaneous/Testing) on GitHub. +> [!TIP] +> SQLite is a relational provider and can also use in-memory databases. +> Consider using this for testing to more closely match common relational database behaviors. +> This is covered in [Using SQLite to test an EF Core application](xref:core/miscellaneous/testing/sqlite). -## InMemory is not a relational database - -EF Core database providers do not have to be relational databases. InMemory is designed to be a general purpose database for testing, and is not designed to mimic a relational database. - -Some examples of this include: - -* InMemory will allow you to save data that would violate referential integrity constraints in a relational database. -* If you use DefaultValueSql(string) for a property in your model, this is a relational database API and will have no effect when running against InMemory. -* [Concurrency via Timestamp/row version](xref:core/modeling/concurrency#timestamprowversion) (`[Timestamp]` or `IsRowVersion`) is not supported. No [DbUpdateConcurrencyException](https://docs.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbupdateconcurrencyexception) will be thrown if an update is done using an old concurrency token. - -> [!TIP] -> For many test purposes these differences will not matter. However, if you want to test against something that behaves more like a true relational database, then consider using [SQLite in-memory mode](sqlite.md). - -## Example testing scenario - -Consider the following service that allows application code to perform some operations related to blogs. Internally it uses a `DbContext` that connects to a SQL Server database. It would be useful to swap this context to connect to an InMemory database so that we can write efficient tests for this service without having to modify the code, or do a lot of work to create a test double of the context. - -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/BusinessLogic/BlogService.cs)] - -## Get your context ready - -### Avoid configuring two database providers - -In your tests you are going to externally configure the context to use the InMemory provider. If you are configuring a database provider by overriding `OnConfiguring` in your context, then you need to add some conditional code to ensure that you only configure the database provider if one has not already been configured. - -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/BusinessLogic/BloggingContext.cs#OnConfiguring)] - -> [!TIP] -> If you are using ASP.NET Core, then you should not need this code since your database provider is already configured outside of the context (in Startup.cs). - -### Add a constructor for testing - -The simplest way to enable testing against a different database is to modify your context to expose a constructor that accepts a `DbContextOptions`. - -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/BusinessLogic/BloggingContext.cs#Constructors)] - -> [!TIP] -> `DbContextOptions` tells the context all of its settings, such as which database to connect to. This is the same object that is built by running the OnConfiguring method in your context. - -## Writing tests - -The key to testing with this provider is the ability to tell the context to use the InMemory provider, and control the scope of the in-memory database. Typically you want a clean database for each test method. - -Here is an example of a test class that uses the InMemory database. Each test method specifies a unique database name, meaning each method has its own InMemory database. - ->[!TIP] -> To use the `.UseInMemoryDatabase()` extension method, reference the NuGet package [Microsoft.EntityFrameworkCore.InMemory](https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.InMemory/). - -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/TestProject/InMemory/BlogServiceTests.cs)] +The information on this page now lives in other locations: +* See [Testing code that uses EF Core](xref:core/miscellaneous/testing/index) for general information on testing with the EF in-memory database. +* See [Sample showing how to test applications that use EF Core](xref:core/miscellaneous/testing/testing-sample) for a sample using the EF in-memory database. +* See [The EF in-memory database provider](xref:core/providers/in-memory/index) for general information about the EF in-memory database. diff --git a/entity-framework/core/miscellaneous/testing/index.md b/entity-framework/core/miscellaneous/testing/index.md index d6a9e15747..a906158e7e 100644 --- a/entity-framework/core/miscellaneous/testing/index.md +++ b/entity-framework/core/miscellaneous/testing/index.md @@ -1,8 +1,8 @@ --- -title: Test components using EF Core - EF Core +title: Testing code that uses EF Core - EF Core description: Different approaches to testing applications that use EF Core author: ajcvickers -ms.date: 03/23/2020 +ms.date: 04/22/2020 uid: core/miscellaneous/testing/index --- # Testing code that uses EF Core @@ -14,6 +14,9 @@ Testing code that accesses a database requires either: This document outlines the trade offs involved in each of these choices and shows how EF Core can be used with each approach. +> [!TIP] +> See [EF Core testing sample](xref:core/miscellaneous/testing/testing-sample) for code demonstrating the concepts introduced here. + ## All database providers are not equal It is very important to understand that EF Core is not designed to abstract every aspect of the underlying database system. @@ -21,10 +24,10 @@ Instead, EF Core is a common set of patterns and concepts that can be used with EF Core database providers then layer database-specific behavior and functionality over this common framework. This allows each database system to do what it does best while still maintaining commonality, where appropriate, with other database systems. -Fundamentally, this means that switching out the database provider will change EF Core behavior and the application can't be expected to function correctly unless it explicitly accounts for all differences in behavior. +Fundamentally, this means that switching out the database provider will change EF Core behavior and the application can't be expected to function correctly unless it explicitly accounts for any differences in behavior. That being said, in many cases doing this will work because there is a high degree of commonality amongst relational databases. This is good and bad. -Good because moving between databases can be relatively easy. +Good because moving between database systems can be relatively easy. Bad because it can give a false sense of security if the application is not fully tested against the new database system. ## Approach 1: Production database system @@ -39,20 +42,22 @@ Luckily, in this case the answer is quite easy: use local or on-premises SQL Ser SQL Azure and SQL Server are extremely similar, so testing against SQL Server is usually a reasonable trade off. That being said, it is still wise to run tests against SQL Azure itself before going into production. -### LocalDb +### LocalDB All the major database systems have some form of "Developer Edition" for local testing. -SQL Server also also has a feature called [LocalDb](/sql/database-engine/configure-windows/sql-server-express-localdb?view=sql-server-ver15). -The primary advantage of LocalDb is that it spins up the database instance on demand. +SQL Server also has a feature called [LocalDB](/sql/database-engine/configure-windows/sql-server-express-localdb?view=sql-server-ver15). +The primary advantage of LocalDB is that it spins up the database instance on demand. This avoids having a database service running on your machine even when you're not running tests. -LocalDb is not without it's issues: +LocalDB is not without it's issues: * It doesn't support everything that [SQL Server Developer Edition](/sql/sql-server/editions-and-components-of-sql-server-2016?view=sql-server-ver15) does. * It isn't available on Linux. * It can cause lag on first test run as the service is spun up. Personally, I've never found it a problem having a database service running on my dev machine and I would generally recommend using Developer Edition instead. -However, it may be appropriate for some people, especially on less powerful dev machines. +However, LocalDB may be appropriate for some people, especially on less powerful dev machines. + +Running SQL Server (or any other database system) in a Docker container (os similar) is another way to avoid running the database system directly on your development machine. ## Approach 2: SQLite @@ -99,8 +104,8 @@ However, we never try to mock DbContext or IQueryable. Doing so is difficult, cumbersome, and fragile. **Don't do it.** -Instead we use the in-memory database when unit testing something that uses DbContext. -In this case using the in-memory database is appropriate because the test is not dependent on database behavior. +Instead we use the EF in-memory database when unit testing something that uses DbContext. +In this case using the EF in-memory database is appropriate because the test is not dependent on database behavior. Just don't do this to test actual database queries or updates. -See [Testing with the in-memory provider](xref:core/miscellaneous/testing/in-memory) for EF Core specific guidance on using the in-memory database for unit testing. +The [EF Core testing sample](xref:core/miscellaneous/testing/testing-sample) demonstrates tests using the EF in-memory database, as well as SQL Server and SQLite. diff --git a/entity-framework/core/miscellaneous/testing/sharing-databases.md b/entity-framework/core/miscellaneous/testing/sharing-databases.md new file mode 100644 index 0000000000..c31bd08618 --- /dev/null +++ b/entity-framework/core/miscellaneous/testing/sharing-databases.md @@ -0,0 +1,103 @@ +--- +title: Sharing databases between tests - EF Core +description: Sample showing how to share a database between multiple tests +author: ajcvickers +ms.date: 04/25/2020 +uid: core/miscellaneous/testing/sharing-databases +--- + +# Sharing databases between tests + +The [EF Core testing sample](xref:core/miscellaneous/testing/testing-sample) showed how to test applications against different database systems. +For that sample, each test created a new database. +This is a good pattern when using SQLite or the EF in-memory database, but it can involve significant overhead when using other database systems. + +This sample builds on the previous sample by moving database creation into a test fixture. +This allows a single SQL Server database to be created and seeded only once for all tests. + +> [!TIP] +> Make sure to work through the [EF Core testing sample](xref:core/miscellaneous/testing/testing-sample) before continuing here. + +It's not difficult to write multiple tests against the same database. +The trick is doing it in a way that the tests don't trip over each other as they run. +This requires understanding: +* How to safely share objects between tests +* When the test framework runs tests in parallel +* How to keep the database in a clean state for every test + +## The fixture + +We will use a test fixture for sharing objects between tests. +The [XUnit documentation](https://xunit.net/docs/shared-context.html) states that a fixture should be used "when you want to create a single test context and share it among all the tests in the class, and have it cleaned up after all the tests in the class have finished." + +> [!TIP] +> This sample uses [XUnit](https://xunit.net/), but similar concepts exist in other testing frameworks, including [NUnit](https://nunit.org/). + +This means that we need to move database creation and seeding to a fixture class. +Here's what it looks like: + +[!code-csharp[SharedDatabaseFixture](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseFixture.cs?name=SharedDatabaseFixture)] + +For now, notice how the constructor: +* Creates a single database connection for the lifetime of the fixture +* Creates and seeds that database by calling the `Seed` method + +Ignore the locking for now; we will come back to it later. + +> [!TIP] +> The creation and seeding code does not need to be async. +> Making it async will complicate the code and will not improve performance or throughput of tests. + +The database is created by first deleting any existing database and then creating a new database. +This ensures that the database matches the current EF model even if it has been changed since the last test run. + +> [!TIP] +> It can be faster to "clean" the existing database using something like [respawn](https://jimmybogard.com/tag/respawn/) rather than re-create it each time. +> However, care must be taken to ensure that the database schema is up-to-date with the EF model when doing this. + +The database connection is disposed when the fixture is disposed. +You may also consider deleting the test database at this point. +However, this will require additional locking and reference counting if the fixture is being shared by multiple test classes. +Also, it is often useful to have the test database still available for debugging failed tests. + +## Using the fixture + +XUnit has a common pattern for associating a test fixture with a class of tests: + +[!code-csharp[UsingTheFixture](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTest.cs?name=UsingTheFixture)] + +XUnit will now create a single fixture instance and pass it to each instance of the test class. +(Remember from the first [testing sample](xref:core/miscellaneous/testing/testing-sample) that XUnit creates a new test class instance every time it runs a test.) +This means that the database will be created and seeded once and then each test will use this database. + +Note that tests within a single class will not be run in parallel. +This means it is safe for each test to use the same database connection, even though the `DbConnection` object is not thread-safe. + +## Maintaining database state + +Tests often need to mutate the test data with inserts, updates, and deletes. +But these changes will then impact other tests which are expecting a clean, seeded database. + +This can be dealt with by running mutating tests inside a transaction. +For example: + +[!code-csharp[CanAddItem](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTest.cs?name=CanAddItem)] + +Notice that the transaction is created as the test starts and disposed when it is finished. +Disposing the transaction causes it to be rolled back, so none of the changes will be seen by other tests. + +The helper method for creating a context (see the fixture code above) accepts this transaction and opts the DbContext into using it. + +## Sharing the fixture + +You may have noticed locking code around database creation and seeding. +This is not needed for this sample since only one class of tests use the fixture, so only a single fixture instance is created. + +However, you may want to use the same fixture with multiple classes of tests. +XUnit will create one fixture instance for each of these classes. +These may be used by different threads running tests in parallel. +Therefore, it is important to have appropriate locking to ensure only one thread does the database creation and seeding. + +> [!TIP] +> A simple `lock` is fine here. +> There is no need to attempt anything more complex, such as any lock-free patterns. diff --git a/entity-framework/core/miscellaneous/testing/sqlite.md b/entity-framework/core/miscellaneous/testing/sqlite.md index 8e5aa0390e..32118a0ef1 100644 --- a/entity-framework/core/miscellaneous/testing/sqlite.md +++ b/entity-framework/core/miscellaneous/testing/sqlite.md @@ -1,49 +1,42 @@ --- title: Testing with SQLite - EF Core -author: rowanmiller -ms.date: 10/27/2016 -ms.assetid: 7a2b75e2-1875-4487-9877-feff0651b5a6 +description: Using SQLite to test an EF Core application +author: ajcvickers +ms.date: 04/24/2020 uid: core/miscellaneous/testing/sqlite --- -# Testing with SQLite +# Using SQLite to test an EF Core application -SQLite has an in-memory mode that allows you to use SQLite to write tests against a relational database, without the overhead of actual database operations. +> [!WARNING] +> Using SQLite can be an effective way to test an EF Core application. +> However, problems can arise where SQLite behaves differently from other database systems. +> See [Testing code that uses EF Core](xref:core/miscellaneous/testing/index) for a discussion of the issues and trade-offs. -> [!TIP] -> You can view this article's [sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/Miscellaneous/Testing) on GitHub +This document builds uses on the concepts introduced in [Sample showing how to test applications that use EF Core](xref:core/miscellaneous/testing/testing-sample). +The code examples shown here come from this sample. -## Example testing scenario +## Using SQLite in-memory databases -Consider the following service that allows application code to perform some operations related to blogs. Internally it uses a `DbContext` that connects to a SQL Server database. It would be useful to swap this context to connect to an in-memory SQLite database so that we can write efficient tests for this service without having to modify the code, or do a lot of work to create a test double of the context. +Normally, SQLite creates databases as simple files and accesses the file in-process with your application. +This is very fast, especially when using a fast [SSD](https://en.wikipedia.org/wiki/Solid-state_drive). -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/BusinessLogic/BlogService.cs)] +SQLite can also use databases created purely in-memory. +This is easy to use with EF Core as long as you understand the in-memory database lifetime: +* The database is created when the connection to it is opened +* The database is deleted when the connection to it is closed -## Get your context ready +EF Core will use an already open connection when given one, and will never attempt to close it. +So the key to using EF Core with an in-memory SQLite database is to open the connection before passing it to EF. -### Avoid configuring two database providers +The [sample](xref:core/miscellaneous/testing/testing-sample) achieves this with the following code: -In your tests you are going to externally configure the context to use the InMemory provider. If you are configuring a database provider by overriding `OnConfiguring` in your context, then you need to add some conditional code to ensure that you only configure the database provider if one has not already been configured. +[!code-csharp[SqliteInMemory](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteInMemoryItemsControllerTest.cs?name=SqliteInMemory)] -> [!TIP] -> If you are using ASP.NET Core, then you should not need this code since your database provider is configured outside of the context (in Startup.cs). +Notice: +* The `CreateInMemoryDatabase` method creates a SQLite in-memory database and opens the connection to it. +* The created `DbConnection` is extracted from the `ContextOptions` and saved. +* The connection is disposed when the test is disposed so that resources are not leaked. -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/BusinessLogic/BloggingContext.cs#OnConfiguring)] - -### Add a constructor for testing - -The simplest way to enable testing against a different database is to modify your context to expose a constructor that accepts a `DbContextOptions`. - -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/BusinessLogic/BloggingContext.cs#Constructors)] - -> [!TIP] -> `DbContextOptions` tells the context all of its settings, such as which database to connect to. This is the same object that is built by running the OnConfiguring method in your context. - -## Writing tests - -The key to testing with this provider is the ability to tell the context to use SQLite, and control the scope of the in-memory database. The scope of the database is controlled by opening and closing the connection. The database is scoped to the duration that the connection is open. Typically you want a clean database for each test method. - ->[!TIP] -> To use `SqliteConnection()` and the `.UseSqlite()` extension method, reference the NuGet package [Microsoft.EntityFrameworkCore.Sqlite](https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.Sqlite/). - -[!code-csharp[Main](../../../../samples/core/Miscellaneous/Testing/TestProject/SQLite/BlogServiceTests.cs)] +> [!NOTE] +> [Issue #16103](https://github.com/dotnet/efcore/issues/16103) is tracking ways to make this connection management easier. diff --git a/entity-framework/core/miscellaneous/testing/testing-sample.md b/entity-framework/core/miscellaneous/testing/testing-sample.md new file mode 100644 index 0000000000..b6f98e575d --- /dev/null +++ b/entity-framework/core/miscellaneous/testing/testing-sample.md @@ -0,0 +1,223 @@ +--- +title: EF Core testing sample - EF Core +description: Sample showing how to test applications that use EF Core +author: ajcvickers +ms.date: 04/22/2020 +uid: core/miscellaneous/testing/testing-sample +no-loc: [Item, Tag, Items, Tags, items, tags] +--- + +# EF Core testing sample + +> [!TIP] +> The code in this document can be found on GitHub as a [runnable sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/Miscellaneous/Testing/ItemsWebApi/). +> Note that some of these tests **are expected to fail**. The reasons for this are explained below. + +This doc walks through a sample for testing code that uses EF Core. + +## The application + +The [sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/Miscellaneous/Testing/ItemsWebApi/) contains two projects: +- ItemsWebApi: A very simple [Web API backed by ASP.NET Core](/aspnet/core/tutorials/first-web-api?view=aspnetcore-3.1&tabs=visual-studio) with a single controller +- Tests: An [XUnit](https://xunit.net/) test project to test the controller + +### The model and business rules + +The model backing this API has two entity types: Items and Tags. + +* Items have a case-sensitive name and a collection of Tags. +* Each Tag has a label and a count representing the number of times it has been applied to the Item. +* Each Item should only have one Tag with with a given label. + * If an item is tagged with the same label more than once, then the count on the existing tag with that label is incremented instead of a new tag being created. +* Deleting an Item should delete all associated Tags. + +#### The Item entity type + +The `Item` entity type: + +[!code-csharp[ItemEntityType](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Item.cs?name=ItemEntityType)] + +And its configuration in `DbContext.OnModelCreating`: + +[!code-csharp[ConfigureItem](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsContext.cs?name=ConfigureItem)] + +Notice that entity type constrains the way it can be used to reflect the domain model and business rules. In particular: +- The primary key is mapped directly to the `_id` field and not exposed publicly + - EF detects and uses the private constructor accepting the primary key value and name. +- The `Name` property is read-only and set only in the constructor. +- Tags are exposed as a `IReadOnlyList` to prevent arbitrary modification. + - EF associates the `Tags` property with the `_tags` backing field by matching their names. + - The `AddTag` method takes a tag label and implements the business rule described above. + That is, a tag is only added for new labels. + Otherwise the count on an existing label is incremented. +- The `Tags` navigation property is configured for a many-to-one relationship + - There is no need for a navigation property from Tag to Item, so it is not included. + - Also, Tag does not define a foreign key property. + Instead, EF will create and manage a property in shadow-state. + +#### The Tag entity type + +The `Tag` entity type: + +[!code-csharp[TagEntityType](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Tag.cs?name=TagEntityType)] + +And its configuration in `DbContext.OnModelCreating`: + +[!code-csharp[ConfigureTag](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsContext.cs?name=ConfigureTag)] + +Similarly to Item, Tag hides its primary key and makes the `Label` property read-only. + +### The ItemsController + +The Web API controller is pretty basic. +It gets a `DbContext` from the dependency injection container through constructor injection: + +[!code-csharp[Constructor](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs?name=Constructor)] + +It has methods to get all Items or an Item with a given name: + +[!code-csharp[Get](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs?name=Get)] + +It has a method to add a new Item: + +[!code-csharp[PostItem](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs?name=PostItem)] + +A method to tag an Item with a label: + +[!code-csharp[PostTag](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs?name=PostTag)] + +And a method to delete an Item and all associated Tags: + +[!code-csharp[DeleteItem](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs?name=DeleteItem)] + +Most validation and error handling has been removed to reduce clutter. + +## The Tests + +The tests are organized to run with multiple database provider configurations: +* The SQL Server provider, which is the provider used by the application +* The SQLite provider +* The SQLite provider using in-memory SQLite databases +* The EF in-memory database provider + +This is achieved by putting all the tests in a base class, then inheriting from this to test with each provider. + +> [!TIP] +> You will need to change the SQL Server connection string if you're not using LocalDB. + +> [!TIP] +> See [Testing with SQLite](xref:core/miscellaneous/testing/sqlite) for guidance on using SQLite for in-memory testing. + +The following two tests are expected to fail: +* `Can_remove_item_and_all_associated_tags` when running with the EF in-memory database provider +* `Can_add_item_differing_only_by_case` when running with the SQL Server provider + +This is covered in more detail below. + +### Setting up and seeding the database + +XUnit, like most testing frameworks, will create a new test class instance for each test run. +Also, XUnit will not run tests within a given test class in parallel. +This means that we can setup and configure the database in the test constructor and it will be in a well-known state for each test. + +> [!TIP] +> This sample recreates the database for each test. +> This works well for SQLite and EF in-memory database testing, but can involve significant overhead with other database systems, including SQL Server. +> Approaches for reducing this overhead are covered in [Sharing databases across tests](xref:core/miscellaneous/testing/sharing-databases). + +When each test is run: +* DbContextOptions are configured for the provider in use and passed to the base class constructor + * These options are stored in a property and used throughout the tests for creating DbContext instances +* A Seed method is called to create and seed the database + * The Seed method ensures the database is clean by deleting it and then re-creating it + * Some well-known test entities are created and saved to the database + +[!code-csharp[Seeding](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs?name=Seeding)] + +Each concrete test class then inherits from this. +For example: + +[!code-csharp[SqliteItemsControllerTest](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteItemsControllerTest.cs?name=SqliteItemsControllerTest)] + +### Test structure + +Even though the application uses dependency injection, the tests do not. +It would be fine to use dependency injection here, but the additional code it requires has little value. +Instead, a DbContext is created using `new` and then directly passed as the dependency to the controller. + +Each test then executes the method under test on the controller and asserts the results are as expected. +For example: + +[!code-csharp[CanGetItems](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs?name=CanGetItems)] + +Notice that different DbContext instances are used to seed the database and run the tests. +This ensures that test is not using (or tripping over) entities tracked by the context when seeding. +It also better matches what happens in web apps and services. + +Tests that mutate the database create a second DbContext instance in the test for similar reasons. +That is, creating a new, clean, context and then reading into it from the database to ensure that the changes really were saved to the database. +For example: + +[!code-csharp[CanAddItem](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs?name=CanAddItem)] + +Two slightly more involved tests cover the business logic around adding tags. + +[!code-csharp[CanAddTag](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs?name=CanAddTag)] + +[!code-csharp[CanUpTagCount](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs?name=CanUpTagCount)] + +## Issues using different database providers + +Testing with a different database system than is used in the production application can lead to problems. +These are covered at the conceptual level in [Testing code that uses EF Core](xref:core/miscellaneous/testing/index). +The sections below cover two examples of such issues demonstrated by the tests in this sample. + +### Test passes when application is broken + +One of the requirements for our application is that, "Items have a case-sensitive name and a collection of Tags." +This is pretty simple to test: + +[!code-csharp[CanAddItemCaseInsensitive](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs?name=CanAddItemCaseInsensitive)] + +Running this test against the EF in-memory database indicates that everything is fine. +Everything still looks fine when using SQLite. +But the test fails when run against SQL Server! + +``` +System.InvalidOperationException : Sequence contains more than one element + at System.Linq.ThrowHelper.ThrowMoreThanOneElementException() + at System.Linq.Enumerable.Single[TSource](IEnumerable`1 source) + at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.Execute[TResult](Expression query) + at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.Execute[TResult](Expression expression) + at System.Linq.Queryable.Single[TSource](IQueryable`1 source, Expression`1 predicate) + at Tests.ItemsControllerTest.Can_add_item_differing_only_by_case() +``` + +This is because both the EF in-memory database and the SQLite database are case-sensitive by default. +SQL Server, on the other hand, is case-insensitive! + +EF Core, by design, does not change these behaviors because forcing a change in case-sensitivity can have big performance impact. + +Once we know this is a problem we can fix the application and compensate in tests. +However, the point here is that this bug could be missed if only testing with the EF in-memory database or SQLite providers. + +### Test fails when application is correct + +Another of the requirements for our application is that, "deleting an Item should delete all associated Tags." +Again, easy to test: + +[!code-csharp[DeleteItem](../../../../samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs?name=DeleteItem)] + +This test passes on SQL Server and SQLite, but fails with the EF in-memory database! + +``` +Assert.False() Failure +Expected: False +Actual: True + at Tests.ItemsControllerTest.Can_remove_item_and_all_associated_tags() +``` + +In this case the application is working correctly because SQL Server supports [cascade deletes](xref:core/saving/cascade-delete). +SQLite also supports cascade deletes, as do most relational databases, so testing this on SQLite works. +On the other hand, the EF in-memory database [does not support cascade deletes](https://github.com/dotnet/efcore/issues/3924). +This means that this part of the application cannot be tested with the EF in-memory database provider. diff --git a/entity-framework/toc.yml b/entity-framework/toc.yml index 54805e8e9c..048af9ba24 100644 --- a/entity-framework/toc.yml +++ b/entity-framework/toc.yml @@ -74,8 +74,12 @@ href: core/miscellaneous/connection-resiliency.md - name: Testing items: - - name: Overview + - name: Testing code that uses EF Core href: core/miscellaneous/testing/index.md + - name: EF Core testing sample + href: core/miscellaneous/testing/testing-sample.md + - name: Sharing databases between tests + href: core/miscellaneous/testing/sharing-databases.md - name: Test with SQLite href: core/miscellaneous/testing/sqlite.md - name: Test with InMemory @@ -112,6 +116,8 @@ href: core/modeling/backing-field.md - name: Value conversions href: core/modeling/value-conversions.md + - name: Value comparers + href: core/modeling/value-comparers.md - name: Data seeding href: core/modeling/data-seeding.md - name: Entity type constructors diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs new file mode 100644 index 0000000000..01ba0afebb --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Controllers/ItemsController.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Items.Controllers +{ + [ApiController] + [Route("[controller]")] + public class ItemsController : ControllerBase + { + #region Constructor + private readonly ItemsContext _context; + + public ItemsController(ItemsContext context) + => _context = context; + #endregion + + #region Get + [HttpGet] + public IEnumerable Get() + => _context.Set().Include(e => e.Tags).OrderBy(e => e.Name); + + [HttpGet] + public Item Get(string itemName) + => _context.Set().Include(e => e.Tags).FirstOrDefault(e => e.Name == itemName); + #endregion + + #region PostItem + [HttpPost] + public ActionResult PostItem(string itemName) + { + var item = _context.Add(new Item(itemName)).Entity; + + _context.SaveChanges(); + + return item; + } + #endregion + + #region PostTag + [HttpPost] + public ActionResult PostTag(string itemName, string tagLabel) + { + var tag = _context + .Set() + .Include(e => e.Tags) + .Single(e => e.Name == itemName) + .AddTag(tagLabel); + + _context.SaveChanges(); + + return tag; + } + #endregion + + #region DeleteItem + [HttpDelete("{itemName}")] + public ActionResult DeleteItem(string itemName) + { + var item = _context + .Set() + .SingleOrDefault(e => e.Name == itemName); + + if (item == null) + { + return NotFound(); + } + + _context.Remove(item); + _context.SaveChanges(); + + return item; + } + #endregion + } +} diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Item.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Item.cs new file mode 100644 index 0000000000..a504e2013b --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Item.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Items +{ + #region ItemEntityType + public class Item + { + private readonly int _id; + private readonly List _tags = new List(); + + private Item(int id, string name) + { + _id = id; + Name = name; + } + + public Item(string name) + { + Name = name; + } + + public Tag AddTag(string label) + { + var tag = _tags.FirstOrDefault(t => t.Label == label); + + if (tag == null) + { + tag = new Tag(label); + _tags.Add(tag); + } + + tag.Count++; + + return tag; + } + + public string Name { get; } + + public IReadOnlyList Tags => _tags; + } + #endregion +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsContext.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsContext.cs new file mode 100644 index 0000000000..bed0ce08e5 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsContext.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; + +namespace Items +{ + public class ItemsContext : DbContext + { + public ItemsContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region ConfigureItem + modelBuilder.Entity( + b => + { + b.Property("_id"); + b.HasKey("_id"); + b.Property(e => e.Name); + b.HasMany(e => e.Tags).WithOne().IsRequired(); + }); + #endregion + + #region ConfigureTag + modelBuilder.Entity( + b => + { + b.Property("_id"); + b.HasKey("_id"); + b.Property(e => e.Label); + }); + #endregion + } + } +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsWebApi.csproj b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsWebApi.csproj new file mode 100644 index 0000000000..4018a5ed41 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/ItemsWebApi.csproj @@ -0,0 +1,12 @@ + + + + netcoreapp3.1 + Items + + + + + + + diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Program.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Program.cs new file mode 100644 index 0000000000..e1a2f6f16a --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Items +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); + } +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Startup.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Startup.cs new file mode 100644 index 0000000000..ab639b06b4 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Startup.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Items +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + + services.AddDbContext(b => b.UseSqlServer( + @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;ConnectRetryCount=0")); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseHttpsRedirection(); + app.UseRouting(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + } + } +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Tag.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Tag.cs new file mode 100644 index 0000000000..df2375f6b5 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/Tag.cs @@ -0,0 +1,21 @@ +namespace Items +{ + #region TagEntityType + public class Tag + { + private readonly int _id; + + private Tag(int id, string label) + { + _id = id; + Label = label; + } + + public Tag(string label) => Label = label; + + public string Label { get; } + + public int Count { get; set; } + } + #endregion +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.Development.json b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.Development.json new file mode 100644 index 0000000000..8983e0fc1c --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.json b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.json new file mode 100644 index 0000000000..d9d9a9bff6 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/ItemsWebApi/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseFixture.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseFixture.cs new file mode 100644 index 0000000000..3f67f74901 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseFixture.cs @@ -0,0 +1,76 @@ +using System; +using System.Data.Common; +using Items; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace SharedDatabaseTests +{ + #region SharedDatabaseFixture + public class SharedDatabaseFixture : IDisposable + { + private static readonly object _lock = new object(); + private static bool _databaseInitialized; + + public SharedDatabaseFixture() + { + Connection = new SqlConnection(@"Server=(localdb)\mssqllocaldb;Database=EFTestSample;ConnectRetryCount=0"); + + Seed(); + + Connection.Open(); + } + + public DbConnection Connection { get; } + + public ItemsContext CreateContext(DbTransaction transaction = null) + { + var context = new ItemsContext(new DbContextOptionsBuilder().UseSqlServer(Connection).Options); + + if (transaction != null) + { + context.Database.UseTransaction(transaction); + } + + return context; + } + + private void Seed() + { + lock (_lock) + { + if (!_databaseInitialized) + { + using (var context = CreateContext()) + { + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + + var one = new Item("ItemOne"); + one.AddTag("Tag11"); + one.AddTag("Tag12"); + one.AddTag("Tag13"); + + var two = new Item("ItemTwo"); + + var three = new Item("ItemThree"); + three.AddTag("Tag31"); + three.AddTag("Tag31"); + three.AddTag("Tag31"); + three.AddTag("Tag32"); + three.AddTag("Tag32"); + + context.AddRange(one, two, three); + + context.SaveChanges(); + } + + _databaseInitialized = true; + } + } + } + + public void Dispose() => Connection.Dispose(); + } + #endregion +} diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTest.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTest.cs new file mode 100644 index 0000000000..b54a136751 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTest.cs @@ -0,0 +1,157 @@ +using System.Linq; +using Items; +using Items.Controllers; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace SharedDatabaseTests +{ + #region UsingTheFixture + public class SharedDatabaseTest : IClassFixture + { + public SharedDatabaseTest(SharedDatabaseFixture fixture) => Fixture = fixture; + + public SharedDatabaseFixture Fixture { get; } + + #endregion + + #region CanGetItems + [Fact] + public void Can_get_items() + { + using (var context = Fixture.CreateContext()) + { + var controller = new ItemsController(context); + + var items = controller.Get().ToList(); + + Assert.Equal(3, items.Count); + Assert.Equal("ItemOne", items[0].Name); + Assert.Equal("ItemThree", items[1].Name); + Assert.Equal("ItemTwo", items[2].Name); + } + } + #endregion + + [Fact] + public void Can_get_item() + { + using (var context = Fixture.CreateContext()) + { + var controller = new ItemsController(context); + + var item = controller.Get("ItemTwo"); + + Assert.Equal("ItemTwo", item.Name); + } + } + + #region CanAddItem + [Fact] + public void Can_add_item() + { + using (var transaction = Fixture.Connection.BeginTransaction()) + { + using (var context = Fixture.CreateContext(transaction)) + { + var controller = new ItemsController(context); + + var item = controller.PostItem("ItemFour").Value; + + Assert.Equal("ItemFour", item.Name); + } + + using (var context = Fixture.CreateContext(transaction)) + { + var item = context.Set().Single(e => e.Name == "ItemFour"); + + Assert.Equal("ItemFour", item.Name); + Assert.Equal(0, item.Tags.Count); + } + } + } + #endregion + + #region CanAddTag + [Fact] + public void Can_add_tag() + { + using (var transaction = Fixture.Connection.BeginTransaction()) + { + using (var context = Fixture.CreateContext(transaction)) + { + var controller = new ItemsController(context); + + var tag = controller.PostTag("ItemTwo", "Tag21").Value; + + Assert.Equal("Tag21", tag.Label); + Assert.Equal(1, tag.Count); + } + + using (var context = Fixture.CreateContext(transaction)) + { + var item = context.Set().Include(e => e.Tags).Single(e => e.Name == "ItemTwo"); + + Assert.Equal(1, item.Tags.Count); + Assert.Equal("Tag21", item.Tags[0].Label); + Assert.Equal(1, item.Tags[0].Count); + } + } + } + #endregion + + #region CanUpTagCount + [Fact] + public void Can_add_tag_when_already_existing_tag() + { + using (var transaction = Fixture.Connection.BeginTransaction()) + { + using (var context = Fixture.CreateContext(transaction)) + { + var controller = new ItemsController(context); + + var tag = controller.PostTag("ItemThree", "Tag32").Value; + + Assert.Equal("Tag32", tag.Label); + Assert.Equal(3, tag.Count); + } + + using (var context = Fixture.CreateContext(transaction)) + { + var item = context.Set().Include(e => e.Tags).Single(e => e.Name == "ItemThree"); + + Assert.Equal(2, item.Tags.Count); + Assert.Equal("Tag31", item.Tags[0].Label); + Assert.Equal(3, item.Tags[0].Count); + Assert.Equal("Tag32", item.Tags[1].Label); + Assert.Equal(3, item.Tags[1].Count); + } + } + } + #endregion + + #region DeleteItem + [Fact] + public void Can_remove_item_and_all_associated_tags() + { + using (var transaction = Fixture.Connection.BeginTransaction()) + { + using (var context = Fixture.CreateContext(transaction)) + { + var controller = new ItemsController(context); + + var item = controller.DeleteItem("ItemThree").Value; + + Assert.Equal("ItemThree", item.Name); + } + + using (var context = Fixture.CreateContext(transaction)) + { + Assert.False(context.Set().Any(e => e.Name == "ItemThree")); + Assert.False(context.Set().Any(e => e.Label.StartsWith("Tag3"))); + } + } + } + #endregion + } +} diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTests.csproj b/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTests.csproj new file mode 100644 index 0000000000..d8814a62ee --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/SharedDatabaseTests/SharedDatabaseTests.csproj @@ -0,0 +1,18 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + + + diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/InMemoryItemsControllerTest.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/InMemoryItemsControllerTest.cs new file mode 100644 index 0000000000..734e7bfa15 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/InMemoryItemsControllerTest.cs @@ -0,0 +1,16 @@ +using Items; +using Microsoft.EntityFrameworkCore; + +namespace Tests +{ + public class InMemoryItemsControllerTest : ItemsControllerTest + { + public InMemoryItemsControllerTest() + : base( + new DbContextOptionsBuilder() + .UseInMemoryDatabase("TestDatabase") + .Options) + { + } + } +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs new file mode 100644 index 0000000000..fc8f1c8001 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/ItemsControllerTest.cs @@ -0,0 +1,198 @@ +using System.Linq; +using Items; +using Items.Controllers; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Tests +{ + public abstract class ItemsControllerTest + { + #region Seeding + protected ItemsControllerTest(DbContextOptions contextOptions) + { + ContextOptions = contextOptions; + + Seed(); + } + + protected DbContextOptions ContextOptions { get; } + + private void Seed() + { + using (var context = new ItemsContext(ContextOptions)) + { + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + + var one = new Item("ItemOne"); + one.AddTag("Tag11"); + one.AddTag("Tag12"); + one.AddTag("Tag13"); + + var two = new Item("ItemTwo"); + + var three = new Item("ItemThree"); + three.AddTag("Tag31"); + three.AddTag("Tag31"); + three.AddTag("Tag31"); + three.AddTag("Tag32"); + three.AddTag("Tag32"); + + context.AddRange(one, two, three); + + context.SaveChanges(); + } + } + #endregion + + #region CanGetItems + [Fact] + public void Can_get_items() + { + using (var context = new ItemsContext(ContextOptions)) + { + var controller = new ItemsController(context); + + var items = controller.Get().ToList(); + + Assert.Equal(3, items.Count); + Assert.Equal("ItemOne", items[0].Name); + Assert.Equal("ItemThree", items[1].Name); + Assert.Equal("ItemTwo", items[2].Name); + } + } + #endregion + + [Fact] + public void Can_get_item() + { + using (var context = new ItemsContext(ContextOptions)) + { + var controller = new ItemsController(context); + + var item = controller.Get("ItemTwo"); + + Assert.Equal("ItemTwo", item.Name); + } + } + + #region CanAddItem + [Fact] + public void Can_add_item() + { + using (var context = new ItemsContext(ContextOptions)) + { + var controller = new ItemsController(context); + + var item = controller.PostItem("ItemFour").Value; + + Assert.Equal("ItemFour", item.Name); + } + + using (var context = new ItemsContext(ContextOptions)) + { + var item = context.Set().Single(e => e.Name == "ItemFour"); + + Assert.Equal("ItemFour", item.Name); + Assert.Equal(0, item.Tags.Count); + } + } + #endregion + + #region CanAddItemCaseInsensitive + [Fact] + public void Can_add_item_differing_only_by_case() + { + using (var context = new ItemsContext(ContextOptions)) + { + var controller = new ItemsController(context); + + var item = controller.PostItem("itemtwo").Value; + + Assert.Equal("itemtwo", item.Name); + } + + using (var context = new ItemsContext(ContextOptions)) + { + var item = context.Set().Single(e => e.Name == "itemtwo"); + + Assert.Equal(0, item.Tags.Count); + } + } + #endregion + + #region CanAddTag + [Fact] + public void Can_add_tag() + { + using (var context = new ItemsContext(ContextOptions)) + { + var controller = new ItemsController(context); + + var tag = controller.PostTag("ItemTwo", "Tag21").Value; + + Assert.Equal("Tag21", tag.Label); + Assert.Equal(1, tag.Count); + } + + using (var context = new ItemsContext(ContextOptions)) + { + var item = context.Set().Include(e => e.Tags).Single(e => e.Name == "ItemTwo"); + + Assert.Equal(1, item.Tags.Count); + Assert.Equal("Tag21", item.Tags[0].Label); + Assert.Equal(1, item.Tags[0].Count); + } + } + #endregion + + #region CanUpTagCount + [Fact] + public void Can_add_tag_when_already_existing_tag() + { + using (var context = new ItemsContext(ContextOptions)) + { + var controller = new ItemsController(context); + + var tag = controller.PostTag("ItemThree", "Tag32").Value; + + Assert.Equal("Tag32", tag.Label); + Assert.Equal(3, tag.Count); + } + + using (var context = new ItemsContext(ContextOptions)) + { + var item = context.Set().Include(e => e.Tags).Single(e => e.Name == "ItemThree"); + + Assert.Equal(2, item.Tags.Count); + Assert.Equal("Tag31", item.Tags[0].Label); + Assert.Equal(3, item.Tags[0].Count); + Assert.Equal("Tag32", item.Tags[1].Label); + Assert.Equal(3, item.Tags[1].Count); + } + } + #endregion + + #region DeleteItem + [Fact] + public void Can_remove_item_and_all_associated_tags() + { + using (var context = new ItemsContext(ContextOptions)) + { + var controller = new ItemsController(context); + + var item = controller.DeleteItem("ItemThree").Value; + + Assert.Equal("ItemThree", item.Name); + } + + using (var context = new ItemsContext(ContextOptions)) + { + Assert.False(context.Set().Any(e => e.Name == "ItemThree")); + Assert.False(context.Set().Any(e => e.Label.StartsWith("Tag3"))); + } + } + #endregion + } +} diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqlServerItemsControllerTest.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqlServerItemsControllerTest.cs new file mode 100644 index 0000000000..f1b68ef192 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqlServerItemsControllerTest.cs @@ -0,0 +1,16 @@ +using Items; +using Microsoft.EntityFrameworkCore; + +namespace Tests +{ + public class SqlServerItemsControllerTest : ItemsControllerTest + { + public SqlServerItemsControllerTest() + : base( + new DbContextOptionsBuilder() + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=EFTestSample;ConnectRetryCount=0") + .Options) + { + } + } +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteInMemoryItemsControllerTest.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteInMemoryItemsControllerTest.cs new file mode 100644 index 0000000000..d65c1f04cd --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteInMemoryItemsControllerTest.cs @@ -0,0 +1,36 @@ +using System; +using System.Data.Common; +using Items; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Tests +{ + #region SqliteInMemory + public class SqliteInMemoryItemsControllerTest : ItemsControllerTest, IDisposable + { + private readonly DbConnection _connection; + + public SqliteInMemoryItemsControllerTest() + : base( + new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options) + { + _connection = RelationalOptionsExtension.Extract(ContextOptions).Connection; + } + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + public void Dispose() => _connection.Dispose(); + } + #endregion +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteItemsControllerTest.cs b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteItemsControllerTest.cs new file mode 100644 index 0000000000..d0c3e68bc8 --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/SqliteItemsControllerTest.cs @@ -0,0 +1,18 @@ +using Items; +using Microsoft.EntityFrameworkCore; + +namespace Tests +{ + #region SqliteItemsControllerTest + public class SqliteItemsControllerTest : ItemsControllerTest + { + public SqliteItemsControllerTest() + : base( + new DbContextOptionsBuilder() + .UseSqlite("Filename=Test.db") + .Options) + { + } + } + #endregion +} \ No newline at end of file diff --git a/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/Tests.csproj b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/Tests.csproj new file mode 100644 index 0000000000..05b44381de --- /dev/null +++ b/samples/core/Miscellaneous/Testing/ItemsWebApi/Tests/Tests.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + + + + + diff --git a/samples/core/Samples.sln b/samples/core/Samples.sln index 4232c9856c..3bdf0759c8 100644 --- a/samples/core/Samples.sln +++ b/samples/core/Samples.sln @@ -51,6 +51,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlServer", "SqlServer\SqlS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ValueConversions", "Modeling\ValueConversions\ValueConversions.csproj", "{FE71504E-C32B-4E2F-9830-21ED448DABC4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ItemsWebApi", "Miscellaneous\Testing\ItemsWebApi\ItemsWebApi\ItemsWebApi.csproj", "{ECF03060-646F-4B62-9446-1953F228CB09}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ItemsWebApi", "ItemsWebApi", "{8695C7BE-F9B2-477A-AD7B-C15DC5418F66}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Miscellaneous\Testing\ItemsWebApi\Tests\Tests.csproj", "{E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedDatabaseTests", "Miscellaneous\Testing\ItemsWebApi\SharedDatabaseTests\SharedDatabaseTests.csproj", "{34C237C8-DD12-4C14-9B15-B7F85C218CDB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -375,6 +383,54 @@ Global {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|x64.Build.0 = Release|Any CPU {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|x86.ActiveCfg = Release|Any CPU {FE71504E-C32B-4E2F-9830-21ED448DABC4}.Release|x86.Build.0 = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|ARM.ActiveCfg = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|ARM.Build.0 = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|x64.ActiveCfg = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|x64.Build.0 = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|x86.ActiveCfg = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Debug|x86.Build.0 = Debug|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|Any CPU.Build.0 = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|ARM.ActiveCfg = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|ARM.Build.0 = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|x64.ActiveCfg = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|x64.Build.0 = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|x86.ActiveCfg = Release|Any CPU + {ECF03060-646F-4B62-9446-1953F228CB09}.Release|x86.Build.0 = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|ARM.ActiveCfg = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|ARM.Build.0 = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|x64.Build.0 = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Debug|x86.Build.0 = Debug|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|Any CPU.Build.0 = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|ARM.ActiveCfg = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|ARM.Build.0 = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|x64.ActiveCfg = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|x64.Build.0 = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|x86.ActiveCfg = Release|Any CPU + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}.Release|x86.Build.0 = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|ARM.ActiveCfg = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|ARM.Build.0 = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|x64.Build.0 = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Debug|x86.Build.0 = Debug|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|Any CPU.Build.0 = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|ARM.ActiveCfg = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|ARM.Build.0 = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|x64.ActiveCfg = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|x64.Build.0 = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|x86.ActiveCfg = Release|Any CPU + {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -393,6 +449,10 @@ Global {802E31AD-2F1E-41A1-A662-5929E2626601} = {CA5046EC-C894-4535-8190-A31F75FDEB96} {63685B9A-1233-4B44-AAC1-8DDD4B16B65D} = {CA5046EC-C894-4535-8190-A31F75FDEB96} {FE71504E-C32B-4E2F-9830-21ED448DABC4} = {CA5046EC-C894-4535-8190-A31F75FDEB96} + {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} = {4E2B02EE-0C76-42D6-BA0A-337D7680A5D6} + {ECF03060-646F-4B62-9446-1953F228CB09} = {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} + {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3} = {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} + {34C237C8-DD12-4C14-9B15-B7F85C218CDB} = {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {20C98D35-54EF-46A6-8F3B-1855C1AE4F70}