Skip to content

Commit 3edb5ac

Browse files
committed
03/05: add exercise texts
1 parent b065452 commit 3edb5ac

File tree

15 files changed

+383
-10
lines changed

15 files changed

+383
-10
lines changed

exercises/03.assertions/04.problem.retryable-assertions/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"type": "module",
3-
"name": "exercises_03.assertions_04.solution.retryable-assertions",
3+
"name": "exercises_03.assertions_04.problem.retryable-assertions",
44
"scripts": {
55
"dev": "vite",
66
"test": "vitest",
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Soft assertions
2+
3+
The software you build tends to get complex, which is a direct reflection of the complexity behind its requirements. Sometimes, you have multiple expectations toward your system even within a single test case (which, by the way, I find to be absolutely normal).
4+
5+
For example, let's say you are testing a user subscription service. There, one of the expectations is that when the user cancels their subscription, their `user.subscriptioon` object must transition to the correct state:
6+
7+
```ts highlight=3-4
8+
user.cancelSubscription()
9+
10+
expect(user.subscription.state).toBe('cancelled')
11+
expect(user.subscription.endsAt).toBe('2025-01-01T00:00:00.000Z')
12+
```
13+
14+
There are two criteria to assume the correct cancellation:
15+
16+
- The `state` property equals to `'cancelled'`;
17+
- The `endsAt` property equals to the first day of the next month after the subscription has been cancelled.
18+
19+
You refelect these criteria in individual `expect()` calls (i.e. assertions).
20+
21+
<callout-success>You can also rely on [Implicit assertions](https://www.epicweb.dev/implicit-assertions), which are a fantastic way to express more expectations without writing additional assertions.</callout-success>
22+
23+
There is a great deal of value to be had from <u>**how**</u> your tests fail. In fact, most of the decisions you make when writing your tests comes down to designing a nice experience around test failures, one way or another.
24+
25+
So, what happens when our subscription test fails?
26+
27+
Right now, it can fail when either of the assertions fails (incorrect state update) or _both of them fail_ (missing state transition from `active` to `cancelled`). In any case, its failure indicates a problem and must help you get to its root cause faster.
28+
29+
Let's imagine this test fails because the subscription never transitioned from `active` to `cancelled`. Your first piece of feedback from the test will be this:
30+
31+
```txt nocopy remove=3 add=4
32+
AssertionError: expected 'active' to be 'cancelled'
33+
34+
Expected: "cancelled"
35+
Received: "active"
36+
```
37+
38+
This is a useful feedback as it clearly indicates an incorrect (or missing) state transition.
39+
40+
**But it doesn't tell you the whole picture.**
41+
42+
It only tells you the result of the first failed assertion. What about the other expectations? Did they succeed or also failed? Was the `endsAt` date calculated correctly, if at all? What if you have three, five, or _ten_ assertions toward the same state? How did those fare?
43+
44+
All of that information is important when debugging issues. But since assertions are sensitive to order and operate on the fail-fast principle, you are locking yourself in a minigame of addressing failed assertions one-by-one instead of grasping the issue as a whole.
45+
46+
## Your task
47+
48+
Let's change that.
49+
50+
👨‍💼 In this one, you will refactor this user subscription test to use _soft assertions_. It's the kind of assertion that still runs and gives value but _doesn't short-circuit the test_ if it fails. Head straight to <InlineFile file="src/user.test.ts" /> and follow the instructions to refactor the test and gain more value out of its failures.
51+
52+
👨‍💼 Bonus points if you track down and fix the issue to have the tests passing.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"type": "module",
3+
"name": "exercises_03.assertions_05.problem.soft-assertions",
4+
"scripts": {
5+
"dev": "vite",
6+
"test": "vitest",
7+
"build": "vite build"
8+
},
9+
"devDependencies": {
10+
"vite": "^6.2.6",
11+
"vitest": "^3.1.1"
12+
}
13+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
export type PlanKind = 'monthly' | 'yearly'
2+
3+
export class Plan {
4+
public name: string
5+
public kind: PlanKind
6+
public state: 'active' | 'cancelled'
7+
public chargeAmount: number
8+
public endsAt?: string
9+
10+
constructor(options: {
11+
name: string
12+
kind: PlanKind
13+
chargeAmount: number
14+
endsAt?: string
15+
}) {
16+
this.name = options.name
17+
this.kind = options.kind
18+
this.chargeAmount = options.chargeAmount
19+
this.state = 'active'
20+
this.endsAt = options.endsAt
21+
}
22+
23+
public cancel() {
24+
if (this.state !== 'active') {
25+
return
26+
}
27+
28+
const today = new Date()
29+
today.setUTCDate(1)
30+
31+
if (today.getUTCMonth() === 0) {
32+
today.setUTCFullYear(today.getUTCFullYear() + 1)
33+
}
34+
35+
this.endsAt = today.toISOString()
36+
}
37+
}
38+
39+
export class TrialPlan extends Plan {
40+
constructor() {
41+
super({
42+
name: 'Trial',
43+
kind: 'monthly',
44+
chargeAmount: 0,
45+
})
46+
}
47+
}
48+
49+
export class UnlimitedPlan extends Plan {
50+
constructor() {
51+
super({
52+
name: 'Unlimited',
53+
kind: 'yearly',
54+
chargeAmount: 250,
55+
})
56+
}
57+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { User } from './user'
2+
import { UnlimitedPlan } from './plans'
3+
4+
beforeAll(() => {
5+
vi.useFakeTimers()
6+
vi.setSystemTime(new Date('2025-12-02T00:00:00Z'))
7+
})
8+
9+
afterAll(() => {
10+
vi.useRealTimers()
11+
})
12+
13+
test('cancels the user subscription', () => {
14+
const user = new User()
15+
user.subscribe(new UnlimitedPlan())
16+
17+
// 🐨 Replace the `expect()` calls with `expect.soft()`.
18+
// This will make these assertions soft.
19+
// 💰 expect.soft()
20+
expect(user.subscription.name).toBe('Unlimited')
21+
expect(user.subscription.kind).toBe('yearly')
22+
expect(user.subscription.state).toBe('active')
23+
expect(user.subscription.endsAt).toBeUndefined()
24+
25+
user.cancelSubscription()
26+
27+
// 🐨 In a similar manner, refactor these assertions
28+
// to be soft assertions.
29+
expect(user.subscription.state).toBe('cancelled')
30+
expect(user.subscription.endsAt).toBe('2026-01-01T00:00:00.000Z')
31+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { TrialPlan, type Plan } from './plans'
2+
3+
export class User {
4+
public subscription: Plan
5+
6+
constructor() {
7+
this.subscription = new TrialPlan()
8+
}
9+
10+
public subscribe(subscription: Plan) {
11+
this.subscription = subscription
12+
}
13+
14+
public cancelSubscription() {
15+
this.subscription.cancel()
16+
}
17+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "./tsconfig.base.json",
3+
"include": ["src/**/*"],
4+
"exclude": ["src/**/*.test.ts*"],
5+
"compilerOptions": {
6+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
7+
"jsx": "react-jsx"
8+
}
9+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "ESNext",
5+
"useDefineForClassFields": true,
6+
"skipLibCheck": true,
7+
8+
/* Bundler mode */
9+
"moduleResolution": "bundler",
10+
"allowImportingTsExtensions": true,
11+
"isolatedModules": true,
12+
"moduleDetection": "force",
13+
"noEmit": true,
14+
"verbatimModuleSyntax": true,
15+
16+
/* Linting */
17+
"strict": true,
18+
"noUnusedLocals": false,
19+
"noUnusedParameters": false,
20+
"noFallthroughCasesInSwitch": true,
21+
"noUncheckedSideEffectImports": true
22+
}
23+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"files": [],
3+
"references": [
4+
{ "path": "./tsconfig.app.json" },
5+
{ "path": "./tsconfig.node.json" },
6+
{ "path": "./tsconfig.test.json" }
7+
]
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "./tsconfig.base.json",
3+
"compilerOptions": {
4+
"lib": ["ES2023"]
5+
},
6+
"include": ["vitest.config.ts"]
7+
}

0 commit comments

Comments
 (0)