Skip to content

Commit fca8d7a

Browse files
dac09cjreimer
andauthored
feat(testing): Add describeScenario utility to group scenario tests (redwoodjs#9866)
Co-authored-by: Curtis Reimer <[email protected]>
1 parent 1a1e758 commit fca8d7a

9 files changed

Lines changed: 395 additions & 40 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Prisma, Contact } from '@prisma/client'
2+
3+
import type { ScenarioData } from '@redwoodjs/testing/api'
4+
5+
export const standard = defineScenario<Prisma.ContactCreateArgs>({
6+
contact: {
7+
one: { data: { name: 'String', email: 'String', message: 'String' } },
8+
two: { data: { name: 'String', email: 'String', message: 'String' } },
9+
},
10+
})
11+
12+
export type StandardScenario = ScenarioData<Contact, 'contact'>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { db } from 'src/lib/db'
2+
3+
import { contact, contacts, createContact } from './contacts'
4+
import type { StandardScenario } from './contacts.scenarios'
5+
6+
/**
7+
* Example test for describe scenario.
8+
*
9+
* Note that scenario tests need a matching [name].scenarios.ts file.
10+
*/
11+
12+
describeScenario<StandardScenario>('contacts', (getScenario) => {
13+
let scenario: StandardScenario
14+
15+
beforeEach(() => {
16+
scenario = getScenario()
17+
})
18+
19+
it('returns all contacts', async () => {
20+
const result = await contacts()
21+
22+
expect(result.length).toEqual(Object.keys(scenario.contact).length)
23+
})
24+
25+
it('returns a single contact', async () => {
26+
const result = await contact({ id: scenario.contact.one.id })
27+
28+
expect(result).toEqual(scenario.contact.one)
29+
})
30+
31+
it('creates a contact', async () => {
32+
const result = await createContact({
33+
input: {
34+
name: 'Bazinga',
35+
36+
message: 'Describe scenario works!',
37+
},
38+
})
39+
40+
expect(result.name).toEqual('Bazinga')
41+
expect(result.email).toEqual('[email protected]')
42+
expect(result.message).toEqual('Describe scenario works!')
43+
})
44+
45+
it('Checking that describe scenario works', async () => {
46+
// This test is dependent on the above test. If you used a normal scenario it would not work
47+
const contactCreatedInAboveTest = await db.contact.findFirst({
48+
where: {
49+
50+
},
51+
})
52+
53+
expect(contactCreatedInAboveTest.message).toEqual(
54+
'Describe scenario works!'
55+
)
56+
})
57+
})

docs/docs/testing.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1696,6 +1696,107 @@ Only the posts scenarios will be present in the database when running the `posts
16961696
16971697
During the run of any single test, there is only ever one scenario's worth of data present in the database: users.standard *or* users.incomplete.
16981698
1699+
### describeScenario - a performance optimisation
1700+
1701+
The scenario feature described above should be the base starting point for setting up test that depend on the database. The scenario sets up the database before each scenario _test_, runs the test, and then tears down (deletes) the database scenario. This ensures that each of your tests are isolated, and that they do not affect each other.
1702+
1703+
**However**, there are some situations where you as the developer may want additional control regarding when the database is setup and torn down - maybe to run your test suite faster.
1704+
1705+
The `describeScenario` function is utilized to run a sequence of multiple tests, with a single database setup and tear-down.
1706+
1707+
```js
1708+
// highlight-next-line
1709+
describeScenario('contacts', (getScenario) => {
1710+
// You can imagine the scenario setup happens here
1711+
1712+
// All these tests now use the same setup 👇
1713+
it('xxx', () => {
1714+
// Notice that the scenario has to be retrieved using the getter
1715+
// highlight-next-line
1716+
const scenario = getScenario()
1717+
//...
1718+
})
1719+
1720+
it('xxx', () => {
1721+
const scenario = getScenario()
1722+
/...
1723+
})
1724+
1725+
})
1726+
```
1727+
1728+
> **CAUTION**: With describeScenario, your tests are no longer isolated. The results, or side-effects, of prior tests can affect later tests.
1729+
1730+
Rationale for using `describeScenario` include:
1731+
<ul>
1732+
<li>Create multi-step tests where the next test is dependent upon the results of the previous test (Note caution above).</li>
1733+
<li>Reduce testing run time. There is an overhead to setting up and tearing down the db on each test, and in some cases a reduced testing run time may be of significant benefit. This may be of benefit where the likelihood of side-effects is low, such as in query testing</li>
1734+
</ul>
1735+
1736+
### describeScenario Examples
1737+
1738+
Following is an example of the use of `describeScenario` to speed up testing of a user query service function, where the risk of side-effects is low.
1739+
1740+
```ts
1741+
// highlight-next-line
1742+
describeScenario<StandardScenario>('user query service', (getScenario) => {
1743+
1744+
let scenario: StandardScenario
1745+
1746+
beforeEach(() => {
1747+
// Grab the scenario before each test
1748+
// highlight-next-line
1749+
scenario = getScenario()
1750+
})
1751+
1752+
it('retrieves a single user for a validated user', async () => {
1753+
mockCurrentUser({ id: 123, name: 'Admin' })
1754+
1755+
const record = await user({ id: scenario.user.dom.id })
1756+
1757+
expect(record.id).toEqual(scenario.user.dom.id)
1758+
})
1759+
1760+
it('throws an error upon an invalid user id', async () => {
1761+
mockCurrentUser({ id: 123, name: 'Admin' })
1762+
1763+
const fcn = async () => await user({ id: null as unknown as number })
1764+
1765+
await expect(fcn).rejects.toThrow()
1766+
})
1767+
1768+
it('throws an error if not authenticated', async () => {
1769+
const fcn = async () => await user({ id: scenario.user.dom.id })
1770+
1771+
await expect(fcn).rejects.toThrow(AuthenticationError)
1772+
})
1773+
1774+
it('throws an error if the user is not authorized to query the user', async () => {
1775+
mockCurrentUser({ id: 999, name: 'BaseLevelUser' })
1776+
1777+
const fcn = async () => await user({ id: scenario.user.dom.id })
1778+
1779+
await expect(fcn).rejects.toThrow(ForbiddenError)
1780+
})
1781+
})
1782+
```
1783+
1784+
:::tip Using named scenarios with describeScenario
1785+
1786+
If you have multiple scenarios, you can also use named scenario with `describeScenario`
1787+
1788+
For example:
1789+
```js
1790+
// If we have a paymentDeclined scenario defined in the .scenario.{js,ts} file
1791+
// The second parameter is the name of the "describe" block
1792+
describeScenario('paymentDeclined', 'Retrieving details', () => {
1793+
// ....
1794+
})
1795+
```
1796+
:::
1797+
1798+
1799+
16991800
### mockCurrentUser() on the API-side
17001801
17011802
Just like when testing the web-side, we can use `mockCurrentUser()` to mock out the user that's currently logged in (or not) on the api-side.
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { Scenario, DefineScenario } from '@redwoodjs/testing/api'
1+
import type { Scenario, DefineScenario, DescribeScenario } from '@redwoodjs/testing/api'
22

33
declare global {
44
/**
55
* Note that the scenario name must match the exports in your {model}.scenarios.ts file
66
*/
77
const scenario: Scenario
8+
const describeScenario: DescribeScenario
89
const defineScenario: DefineScenario
910
}

packages/testing/config/jest/api/jest.setup.js

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,12 @@ const getProjectDb = () => {
8282
return db
8383
}
8484

85+
/**
86+
* Wraps "it" or "test", to seed and teardown the scenario after each test
87+
* This one passes scenario data to the test function
88+
*/
8589
const buildScenario =
86-
(it, testPath) =>
90+
(itFunc, testPath) =>
8791
(...args) => {
8892
let scenarioName, testName, testFunc
8993

@@ -96,39 +100,54 @@ const buildScenario =
96100
throw new Error('scenario() requires 2 or 3 arguments')
97101
}
98102

99-
return it(testName, async () => {
100-
const path = require('path')
101-
const testFileDir = path.parse(testPath)
102-
// e.g. ['comments', 'test'] or ['signup', 'state', 'machine', 'test']
103-
const testFileNameParts = testFileDir.name.split('.')
104-
const testFilePath = `${testFileDir.dir}/${testFileNameParts
105-
.slice(0, testFileNameParts.length - 1)
106-
.join('.')}.scenarios`
107-
let allScenarios, scenario, result
108-
109-
try {
110-
allScenarios = require(testFilePath)
111-
} catch (e) {
112-
// ignore error if scenario file not found, otherwise re-throw
113-
if (e.code !== 'MODULE_NOT_FOUND') {
114-
throw e
115-
}
103+
return itFunc(testName, async () => {
104+
let { scenario } = loadScenarios(testPath, scenarioName)
105+
106+
const scenarioData = await seedScenario(scenario)
107+
const result = await testFunc(scenarioData)
108+
109+
if (wasDbUsed()) {
110+
await teardown()
116111
}
117112

118-
if (allScenarios) {
119-
if (allScenarios[scenarioName]) {
120-
scenario = allScenarios[scenarioName]
121-
} else {
122-
throw new Error(
123-
`UndefinedScenario: There is no scenario named "${scenarioName}" in ${testFilePath}.{js,ts}`
124-
)
113+
return result
114+
})
115+
}
116+
117+
/**
118+
* This creates a describe() block that will seed the scenario ONCE before all tests in the block
119+
* Note that you need to use the getScenario() function to get the data.
120+
*/
121+
const buildDescribeScenario =
122+
(describeFunc, testPath) =>
123+
(...args) => {
124+
let scenarioName, describeBlockName, describeBlock
125+
126+
if (args.length === 3) {
127+
;[scenarioName, describeBlockName, describeBlock] = args
128+
} else if (args.length === 2) {
129+
scenarioName = DEFAULT_SCENARIO
130+
;[describeBlockName, describeBlock] = args
131+
} else {
132+
throw new Error('describeScenario() requires 2 or 3 arguments')
133+
}
134+
135+
return describeFunc(describeBlockName, () => {
136+
let scenarioData
137+
beforeAll(async () => {
138+
let { scenario } = loadScenarios(testPath, scenarioName)
139+
scenarioData = await seedScenario(scenario)
140+
})
141+
142+
afterAll(async () => {
143+
if (wasDbUsed()) {
144+
await teardown()
125145
}
126-
}
146+
})
127147

128-
const scenarioData = await seedScenario(scenario)
129-
result = await testFunc(scenarioData)
148+
const getScenario = () => scenarioData
130149

131-
return result
150+
describeBlock(getScenario)
132151
})
133152
}
134153

@@ -189,6 +208,14 @@ const seedScenario = async (scenario) => {
189208

190209
global.scenario = buildScenario(global.it, global.testPath)
191210
global.scenario.only = buildScenario(global.it.only, global.testPath)
211+
global.describeScenario = buildDescribeScenario(
212+
global.describe,
213+
global.testPath
214+
)
215+
global.describeScenario.only = buildDescribeScenario(
216+
global.describe.only,
217+
global.testPath
218+
)
192219

193220
/**
194221
*
@@ -261,8 +288,33 @@ afterAll(async () => {
261288
}
262289
})
263290

264-
afterEach(async () => {
265-
if (wasDbUsed()) {
266-
await teardown()
291+
function loadScenarios(testPath, scenarioName) {
292+
const path = require('path')
293+
const testFileDir = path.parse(testPath)
294+
// e.g. ['comments', 'test'] or ['signup', 'state', 'machine', 'test']
295+
const testFileNameParts = testFileDir.name.split('.')
296+
const testFilePath = `${testFileDir.dir}/${testFileNameParts
297+
.slice(0, testFileNameParts.length - 1)
298+
.join('.')}.scenarios`
299+
let allScenarios, scenario
300+
301+
try {
302+
allScenarios = require(testFilePath)
303+
} catch (e) {
304+
// ignore error if scenario file not found, otherwise re-throw
305+
if (e.code !== 'MODULE_NOT_FOUND') {
306+
throw e
307+
}
267308
}
268-
})
309+
310+
if (allScenarios) {
311+
if (allScenarios[scenarioName]) {
312+
scenario = allScenarios[scenarioName]
313+
} else {
314+
throw new Error(
315+
`UndefinedScenario: There is no scenario named "${scenarioName}" in ${testFilePath}.{js,ts}`
316+
)
317+
}
318+
}
319+
return { scenario }
320+
}

packages/testing/src/api/scenario.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ interface TestFunctionWithScenario<TData> {
110110
(scenario?: TData): Promise<void>
111111
}
112112

113+
interface DescribeBlockWithGetScenario<TData> {
114+
(getScenario?: () => TData): void
115+
}
116+
113117
export interface Scenario {
114118
(title: string, testFunction: TestFunctionWithScenario<any>): void
115119
}
@@ -126,3 +130,30 @@ export interface Scenario {
126130
export interface Scenario {
127131
only: Scenario
128132
}
133+
134+
export interface DescribeScenario {
135+
<TData = any>(
136+
title: string,
137+
describeBlock: DescribeBlockWithGetScenario<TData>
138+
): void
139+
}
140+
141+
export interface DescribeScenario {
142+
<TData>(
143+
title: string,
144+
describeBlock: DescribeBlockWithGetScenario<TData>
145+
): void
146+
}
147+
148+
// Overload for namedScenario
149+
export interface DescribeScenario {
150+
<TData>(
151+
namedScenario: string,
152+
title: string,
153+
describeBlock: DescribeBlockWithGetScenario<TData>
154+
): void
155+
}
156+
157+
export interface DescribeScenario {
158+
only: DescribeScenario
159+
}

0 commit comments

Comments
 (0)