Skip to content

Commit 7914462

Browse files
Added unit testing
1 parent 7942ed1 commit 7914462

File tree

14 files changed

+1985
-12
lines changed

14 files changed

+1985
-12
lines changed

docs/.vitepress/config.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default defineConfig({
107107
link: "/pages/intermediate/iterators-enumerables",
108108
},
109109
{
110-
text: "🚧 Unit Testing",
110+
text: "Unit Testing",
111111
link: "/pages/intermediate/unit-testing",
112112
},
113113
{

docs/pages/basics/projects.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Both support the concept of projects. In .NET, these are sometimes called "libra
88

99
Typically, C# projects will have a `.sln` file which you can think of as being equivalent to a workspace config file for Node.js projects. When you add additional projects, these need to be registered in the `.sln` file. The `.sln` is created automatically and typically I would avoid editing manually. There's not much setup involved on the C# side
1010

11-
For node, different project managers have different workspace configurations. Here, [we'll look at NPM workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces). There's a bit more setup required and it's different depending on your preferred package manager.
11+
For Node, different project managers have different workspace configurations. Here, [we'll look at NPM workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces). There's a bit more setup required and it's different depending on your preferred package manager.
1212

1313
<CodeSplitter>
1414
<template #left>

docs/pages/intermediate/databases-and-orms.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ await db.SaveChangesAsync();
335335
</template>
336336
</CodeSplitter>
337337

338-
In Entity Framework, model mutations are *tracked* and not written to the database until an explicit call to `SaveChangesAsync()` whereas Prisma performs a direct mutation on the database on each call to `create()` or `update()`. In .NET with EF, the running code can continue to modify the model and add records and flush the changes in one transaction.
338+
A keen eye might notice that the `Add()` is not `async` in Entity Framework while it is `await`ed in Prisma. In Entity Framework, model mutations are *tracked* and not written to the database until an explicit call to `SaveChangesAsync()` which produces one transaction with all tracked model mutations. On the other hand, Prisma performs a direct mutation on the database on each call to `create()` or `update()` and multiple updates across related entities requires manual transaction management. In .NET with EF, the running code can continue to modify the model and add records and flush the changes in one atomic transaction.
339339

340340
This can be very useful when constructing large object graphs.
341341

Lines changed: 294 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,302 @@
11
# Unit Testing
22

3-
🚧 WIP
3+
Both C# and TypeScript have mature and widely adopted unit testing libraries that help teams deliver robust software.
44

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.
66

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.
148

159
## Setup
1610

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+
17199
## 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/)

src/csharp/csharp-notebook.dib

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,10 @@ enum Status {
793793
Error = 16
794794
}
795795

796+
var status = Status.Processing;
797+
798+
Console.WriteLine(status);
799+
796800
var completedSuccessfully = Status.Completed | Status.Success;
797801

798802
if ((completedSuccessfully & Status.Success) == Status.Success) {
@@ -804,3 +808,29 @@ var completedWithError = Status.Completed | Status.Error;
804808
if ((completedWithError & Status.Error) == Status.Error) {
805809
Console.WriteLine("Completed with error");
806810
}
811+
812+
#!csharp
813+
814+
using System.Text.Json;
815+
using System.Text.Json.Serialization;
816+
817+
enum Status {
818+
Default = 0,
819+
Queued = 1,
820+
Processing = 2,
821+
Completed = 4,
822+
Success = 8,
823+
Error = 16
824+
}
825+
826+
record Job(string Name, Status Status);
827+
828+
var options = new JsonSerializerOptions {
829+
Converters = { new JsonStringEnumConverter() }
830+
};
831+
832+
var job1 = new Job("job1", Status.Completed);
833+
834+
Console.WriteLine(JsonSerializer.Serialize(job1));
835+
836+
Console.WriteLine(JsonSerializer.Serialize(job1, options));

0 commit comments

Comments
 (0)