|
1 | 1 | # Unit Testing |
2 | 2 |
|
3 | | -🚧 WIP |
| 3 | +Both C# and TypeScript have mature and widely adopted unit testing libraries that help teams deliver robust software. |
4 | 4 |
|
5 | | -## Basics |
| 5 | +With TypeScript, teams will typically select **Jest** or **Vitest**. There are multiple choices for unit testing in C# including **XUnit**, **NUnit**, **TUnit**, and Microsoft's first party **MSTest**. In general, there isn't much variation between these, but there are some philosophical differences in how they handle setup and teardown with XUnit, for example, using the constructor and `IDisposable` as the setup and teardown interface. |
6 | 6 |
|
7 | | -- [FluentAssertions](https://fluentassertions.com/introduction) |
8 | | -- [Shouldly](https://docs.shouldly.org/) |
9 | | -- [XUnit](https://xunit.net/#documentation) |
10 | | -- [NUnit](https://docs.nunit.org/) |
11 | | -- [Moq](https://github.com/devlooped/moq) |
12 | | -- [NSubstitute](https://nsubstitute.github.io/) |
13 | | -- [FakeItEasy](https://fakeiteasy.github.io/docs/8.3.0/) |
| 7 | +However, the style and approach of writing unit tests tends to be different in C# and TypeScript with C# unit test frameworks generally adopting a class-based approach (with the exception of the **ScenarioTests** extension to NUnit) while both Jest and Vitest use a functional approach. |
14 | 8 |
|
15 | 9 | ## Setup |
16 | 10 |
|
| 11 | +For Node.js, we'll use [Vitest](https://vitest.dev/) and for C#, we'll give an up-and-coming library [TUnit](https://thomhurst.github.io/TUnit/) a try! |
| 12 | + |
| 13 | +<CodeSplitter> |
| 14 | + <template #left> |
| 15 | + |
| 16 | +```shell |
| 17 | +# /src/typescript/vitest-example |
| 18 | +npm init -y |
| 19 | +tsc --init . |
| 20 | +npm install -D vitest |
| 21 | + |
| 22 | +# Mac, Linux |
| 23 | +touch example.test.ts |
| 24 | +touch model.ts |
| 25 | + |
| 26 | +# Windows (PowerShell) |
| 27 | +New-Item example.test.ts |
| 28 | +New-Item model.ts |
| 29 | +``` |
| 30 | + |
| 31 | + </template> |
| 32 | + <template #right> |
| 33 | + |
| 34 | +```shell |
| 35 | +# /src/csharp/tunit-example |
| 36 | +dotnet new classlib |
| 37 | +dotnet add package TUnit # For unit testing |
| 38 | +dotnet add package NSubstitute # For mocking |
| 39 | + |
| 40 | +# Mac, Linux |
| 41 | +mv Class1.cs Example.Test.cs |
| 42 | +touch Model.cs |
| 43 | + |
| 44 | +# Windows |
| 45 | +ren Class1.cs Example.Test.cs |
| 46 | +New-Item Model.cs |
| 47 | + |
| 48 | +# At the root |
| 49 | +dotnet sln add src/csharp/tunit-example |
| 50 | +``` |
| 51 | + |
| 52 | + </template> |
| 53 | +</CodeSplitter> |
| 54 | + |
| 55 | +For Node, we can set up scripts for different run modes for the test: |
| 56 | + |
| 57 | +```json |
| 58 | +// Modify package.json |
| 59 | +{ |
| 60 | + "scripts": { |
| 61 | + "test": "vitest run", // Run just once |
| 62 | + "test:watch": "vitest" // Run and watch for changes and re-run |
| 63 | + }, |
| 64 | +} |
| 65 | +``` |
| 66 | + |
| 67 | +I find that testing tools on Node.js like Vitest to be quite nice in several ways: |
| 68 | + |
| 69 | +1. They generally provide great options for output and visualization to the console whereas C# tools generally expect output to some *other* system for display (e.g. CI). |
| 70 | +2. They provide more options for filtering tests like re-running only failed tests and so on. |
| 71 | + |
| 72 | +## Basics |
| 73 | + |
| 74 | +Let's create a simple model and a simple set of tests. |
| 75 | + |
| 76 | +<CodeSplitter> |
| 77 | + <template #left> |
| 78 | + |
| 79 | +```ts{23,29} |
| 80 | +// 📄 model.ts |
| 81 | +export class User { |
| 82 | + constructor( |
| 83 | + public readonly firstName: string, |
| 84 | + public readonly lastName: string, |
| 85 | + public readonly email: string |
| 86 | + ) {} |
| 87 | +
|
| 88 | + get displayName() { |
| 89 | + return `${this.firstName} ${this.lastName}`; |
| 90 | + } |
| 91 | +
|
| 92 | + get handle() { |
| 93 | + return `@${this.email.split("@")[0]}`; |
| 94 | + } |
| 95 | +} |
| 96 | +
|
| 97 | +// 📄 example.test.ts |
| 98 | +import { describe, test, expect } from "vitest"; |
| 99 | +import { User } from "./model"; |
| 100 | +
|
| 101 | +describe("User creation", () => { |
| 102 | + test("user display name is formatted correctly", () => { |
| 103 | + const user = new User("Charles", "Chen", "[email protected]"); |
| 104 | +
|
| 105 | + expect(user.displayName).toBe("Charles Chen"); |
| 106 | + }); |
| 107 | +
|
| 108 | + test("user handle is email with @", () => { |
| 109 | + const user = new User("Ada", "Lovelace", "[email protected]"); |
| 110 | +
|
| 111 | + expect(user.displayName).toBe("Ada Lovelace"); |
| 112 | + expect(user.handle).toBe("@alove"); |
| 113 | + }); |
| 114 | +}); |
| 115 | +
|
| 116 | +
|
| 117 | +// npm run test 👈 |
| 118 | +// ✓ example.test.ts (2 tests) 1ms |
| 119 | +// ✓ User creation > user display name is formatted correctly |
| 120 | +// ✓ User creation > user handle is email with @ |
| 121 | +
|
| 122 | +// Test Files 1 passed (1) |
| 123 | +// Tests 2 passed (2) |
| 124 | +// Start at 19:26:13 |
| 125 | +// Duration 298ms (transform 72ms, setup 0ms, collect 49ms, tests 1ms, environment 0ms, prepare 67ms) |
| 126 | +// ------------------------------ |
| 127 | +// npm run test:watch 👈 Test with watch mode |
| 128 | +
|
| 129 | +``` |
| 130 | + |
| 131 | + </template> |
| 132 | + <template #right> |
| 133 | + |
| 134 | +```csharp{14,15,21,22} |
| 135 | +// 📄 Model.cs |
| 136 | +public record User( |
| 137 | + string FirstName, |
| 138 | + string LastName, |
| 139 | + string Email |
| 140 | +) { |
| 141 | + public string DisplayName => $"{FirstName} {LastName}"; |
| 142 | +
|
| 143 | + public string Handle => $"@{Email.Split('@')[0]}"; |
| 144 | +} |
| 145 | +
|
| 146 | +// 📄 Example.Test.cs |
| 147 | +public class User_Creation { |
| 148 | + [Test] |
| 149 | + public async Task User_Display_Name_Is_Formatted_Correctly() { |
| 150 | + var user = new User("Charles", "Chen", "[email protected]"); |
| 151 | +
|
| 152 | + await Assert.That(user.DisplayName).IsEqualTo("Charles Chen"); |
| 153 | + } |
| 154 | +
|
| 155 | + [Test] |
| 156 | + public async Task User_Handle_Is_Email_With_At() { |
| 157 | + var user = new User("Ada", "Lovelace", "[email protected]"); |
| 158 | +
|
| 159 | + await Assert.That(user.DisplayName).IsEqualTo("Ada Lovelace"); |
| 160 | + await Assert.That(user.Handle).IsEqualTo("@alove"); |
| 161 | + } |
| 162 | +} |
| 163 | +
|
| 164 | +// dotnet test 👈 |
| 165 | +// Test summary: total: 2, failed: 0, succeeded: 2, skipped: 0, duration: 0.3s |
| 166 | +// Build succeeded in 1.0s |
| 167 | +// ------------------------------ |
| 168 | +// dotnet watch test 👈 Test with watch mode |
| 169 | +
|
| 170 | +``` |
| 171 | + |
| 172 | + </template> |
| 173 | +</CodeSplitter> |
| 174 | + |
| 175 | +::: info |
| 176 | +In C#, tests are always organized into classes and generally use attributes like `[Test]` to decorate the test cases whereas in TypeScript and JavaScript, the standard is to use a `describe()` compatible API. |
| 177 | +::: |
| 178 | + |
| 179 | +In Node, it is common practice to place tests along-side of your code whereas in C#, it is more common to place tests in a separate project. This is because the typical build process for Node.js will strip/exclude files marked with `.test` or `.spec` so they are not included in the final output. |
| 180 | + |
| 181 | +On the other hand, `.cs` files are built into a binary `.dll` so developers typically do not place them side-by-side. However, this *can* be done (just not general practice) by simply excluding these files from the build process for the release configuration: |
| 182 | + |
| 183 | +```xml |
| 184 | +<!-- |
| 185 | +See: /src/csharp/ef-api/ef-api.csproj |
| 186 | +--> |
| 187 | +<Project> |
| 188 | + <!-- Remove .Test.cs files on release build --> |
| 189 | + <ItemGroup Condition="'$(Configuration)' == 'Release'"> |
| 190 | + <Compile Remove="**\*.Tests.cs" /> |
| 191 | + </ItemGroup> |
| 192 | +</Project> |
| 193 | +``` |
| 194 | + |
| 195 | +Now when the project is built for release, the test files will be excluded and tests can be placed alongside of the code. |
| 196 | + |
| 197 | +One small downside to this is that because `.cs` files are compiled into a binary, this means that changes to only the test code will also require the code under test to be rebuilt as well whereas splitting out the test code into a separate project means that changes only to the test code do not require the code under test to be rebuilt. |
| 198 | + |
17 | 199 | ## Mocking |
| 200 | + |
| 201 | +Let's expand our model with a service and repository that interfaces with the database. |
| 202 | + |
| 203 | +Unless we're writing integration tests, here we will want to replace the `UserRepository` with a mock so that we don't write to the database while we are testing our code. |
| 204 | + |
| 205 | +Vitest has [mocking functionality](https://vitest.dev/guide/mocking.html) included so we'll use it. For C#, we'll use [NSubstitute](https://nsubstitute.github.io/). |
| 206 | + |
| 207 | +<CodeSplitter> |
| 208 | + <template #left> |
| 209 | + |
| 210 | +```ts{22,30} |
| 211 | +// 📄 model.ts |
| 212 | +export class UserRepository { |
| 213 | + saveToDb(user: User) { |
| 214 | + // TODO: Actual database save. |
| 215 | + console.log("Saved user (from real repository"); |
| 216 | + } |
| 217 | +} |
| 218 | +
|
| 219 | +export class UserService { |
| 220 | + constructor(private readonly userRepository: UserRepository) {} |
| 221 | +
|
| 222 | + saveUser(user: User) { |
| 223 | + // TODO: Do validation, prepare model, etc. |
| 224 | + this.userRepository.saveToDb(user); |
| 225 | + } |
| 226 | +} |
| 227 | +
|
| 228 | +// 📄 example.test.ts |
| 229 | +test("user saved to database", () => { |
| 230 | + const UserRepository = vi.fn(); |
| 231 | + UserRepository.prototype.saveToDb = vi.fn( |
| 232 | + () => "Saved user (from mock repository)" |
| 233 | + ); |
| 234 | +
|
| 235 | + const repo = new UserRepository(); |
| 236 | + const userService = new UserService(repo); |
| 237 | + const user = new User("Ada", "Lovelace", "[email protected]"); |
| 238 | + const msg = userService.saveUser(user); |
| 239 | +
|
| 240 | + expect(msg).toBe("Saved user (from mock repository)"); |
| 241 | +}); |
| 242 | +``` |
| 243 | + |
| 244 | + </template> |
| 245 | + <template #right> |
| 246 | + |
| 247 | +```csharp{3,4,24,29} |
| 248 | +// 📄 Model.ts |
| 249 | +public class UserRepository { |
| 250 | + // 👇 Important: this needs to be virtual |
| 251 | + public virtual string SaveToDb(User user) { |
| 252 | + // TODO: Actual database save. |
| 253 | + return "Saved user (from real repository)"; |
| 254 | + } |
| 255 | +} |
| 256 | +
|
| 257 | +public class UserService(UserRepository userRepository) { |
| 258 | + public string SaveUser(User user) { |
| 259 | + // TODO: Do validation, prepare model, etc. |
| 260 | + return userRepository.SaveToDb(user); |
| 261 | + } |
| 262 | +} |
| 263 | +
|
| 264 | +// 📄 Example.Test.ts |
| 265 | +[Test] |
| 266 | +public async Task User_Save_To_Database() |
| 267 | +{ |
| 268 | + var user = new User("Ada", "Lovelace", "[email protected]"); |
| 269 | +
|
| 270 | + var mockRepo = Substitute.For<UserRepository>(); |
| 271 | + mockRepo.SaveToDb(user).Returns("Saved user (from mock repository)"); |
| 272 | +
|
| 273 | + var userService = new UserService(mockRepo); |
| 274 | + var msg = userService.SaveUser(user); |
| 275 | +
|
| 276 | + await Assert.That(msg).IsEqualTo("Saved user (from mock repository)"); |
| 277 | +} |
| 278 | +``` |
| 279 | + |
| 280 | + </template> |
| 281 | +</CodeSplitter> |
| 282 | + |
| 283 | +On the C# side, it is very important to note that C# unit testing requires that entities that require mocking are either implemented from interfaces OR have [`virtual` members](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/virtual) which allow the member to be overridden in an inheriting class. |
| 284 | + |
| 285 | +What mocking frameworks in C# do is to create a proxy that inherits from the class and replaces the the original call with a call to the proxy. |
| 286 | + |
| 287 | +In JS, the function simply gets replaced. |
| 288 | + |
| 289 | +## C# Unit Testing Tools |
| 290 | + |
| 291 | +- Fluent assertions: |
| 292 | + - [Shouldly](https://docs.shouldly.org/) |
| 293 | + - [FluentAssertions](https://fluentassertions.com/introduction) |
| 294 | +- Unit tests: |
| 295 | + - [XUnit](https://xunit.net/#documentation) |
| 296 | + - [ScenarioTests](https://github.com/koenbeuk/ScenarioTests) |
| 297 | + - [NUnit](https://docs.nunit.org/) |
| 298 | + - [TUnit](https://thomhurst.github.io/TUnit/) |
| 299 | +- Mocking |
| 300 | + - [Moq](https://github.com/devlooped/moq) |
| 301 | + - [NSubstitute](https://nsubstitute.github.io/) |
| 302 | + - [FakeItEasy](https://fakeiteasy.github.io/docs/8.3.0/) |
0 commit comments