From fca8d7addae7a7dd975bde7b5f86a56f3c7778e5 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Mon, 22 Jan 2024 22:08:48 +0700 Subject: [PATCH] feat(testing): Add `describeScenario` utility to group scenario tests (#9866) Co-authored-by: Curtis Reimer <51102303+cjreimer@users.noreply.github.com> --- .../contacts/describeContacts.scenarios.ts | 12 ++ .../contacts/describeContacts.test.ts | 57 +++++++++ docs/docs/testing.md | 101 +++++++++++++++ .../templates/api-scenarios.d.ts.template | 3 +- .../testing/config/jest/api/jest.setup.js | 118 +++++++++++++----- packages/testing/src/api/scenario.ts | 31 +++++ tasks/test-project/tasks.js | 35 +++++- ...contacts.describeScenario.test.ts.template | 55 ++++++++ tasks/test-project/tui-tasks.js | 23 ++++ 9 files changed, 395 insertions(+), 40 deletions(-) create mode 100644 __fixtures__/test-project/api/src/services/contacts/describeContacts.scenarios.ts create mode 100644 __fixtures__/test-project/api/src/services/contacts/describeContacts.test.ts create mode 100644 tasks/test-project/templates/api/contacts.describeScenario.test.ts.template diff --git a/__fixtures__/test-project/api/src/services/contacts/describeContacts.scenarios.ts b/__fixtures__/test-project/api/src/services/contacts/describeContacts.scenarios.ts new file mode 100644 index 000000000000..37b062292193 --- /dev/null +++ b/__fixtures__/test-project/api/src/services/contacts/describeContacts.scenarios.ts @@ -0,0 +1,12 @@ +import type { Prisma, Contact } from '@prisma/client' + +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + contact: { + one: { data: { name: 'String', email: 'String', message: 'String' } }, + two: { data: { name: 'String', email: 'String', message: 'String' } }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/__fixtures__/test-project/api/src/services/contacts/describeContacts.test.ts b/__fixtures__/test-project/api/src/services/contacts/describeContacts.test.ts new file mode 100644 index 000000000000..701c9408ab37 --- /dev/null +++ b/__fixtures__/test-project/api/src/services/contacts/describeContacts.test.ts @@ -0,0 +1,57 @@ +import { db } from 'src/lib/db' + +import { contact, contacts, createContact } from './contacts' +import type { StandardScenario } from './contacts.scenarios' + +/** + * Example test for describe scenario. + * + * Note that scenario tests need a matching [name].scenarios.ts file. + */ + +describeScenario('contacts', (getScenario) => { + let scenario: StandardScenario + + beforeEach(() => { + scenario = getScenario() + }) + + it('returns all contacts', async () => { + const result = await contacts() + + expect(result.length).toEqual(Object.keys(scenario.contact).length) + }) + + it('returns a single contact', async () => { + const result = await contact({ id: scenario.contact.one.id }) + + expect(result).toEqual(scenario.contact.one) + }) + + it('creates a contact', async () => { + const result = await createContact({ + input: { + name: 'Bazinga', + email: 'contact@describe.scenario', + message: 'Describe scenario works!', + }, + }) + + expect(result.name).toEqual('Bazinga') + expect(result.email).toEqual('contact@describe.scenario') + expect(result.message).toEqual('Describe scenario works!') + }) + + it('Checking that describe scenario works', async () => { + // This test is dependent on the above test. If you used a normal scenario it would not work + const contactCreatedInAboveTest = await db.contact.findFirst({ + where: { + email: 'contact@describe.scenario', + }, + }) + + expect(contactCreatedInAboveTest.message).toEqual( + 'Describe scenario works!' + ) + }) +}) diff --git a/docs/docs/testing.md b/docs/docs/testing.md index 12ee3579ad08..ea0f30d55173 100644 --- a/docs/docs/testing.md +++ b/docs/docs/testing.md @@ -1696,6 +1696,107 @@ Only the posts scenarios will be present in the database when running the `posts 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. +### describeScenario - a performance optimisation + +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. + +**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. + +The `describeScenario` function is utilized to run a sequence of multiple tests, with a single database setup and tear-down. + +```js +// highlight-next-line +describeScenario('contacts', (getScenario) => { + // You can imagine the scenario setup happens here + + // All these tests now use the same setup 👇 + it('xxx', () => { + // Notice that the scenario has to be retrieved using the getter + // highlight-next-line + const scenario = getScenario() + //... + }) + + it('xxx', () => { + const scenario = getScenario() + /... + }) + +}) +``` + +> **CAUTION**: With describeScenario, your tests are no longer isolated. The results, or side-effects, of prior tests can affect later tests. + +Rationale for using `describeScenario` include: +
    +
  • Create multi-step tests where the next test is dependent upon the results of the previous test (Note caution above).
  • +
  • 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
  • +
+ +### describeScenario Examples + +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. + +```ts +// highlight-next-line +describeScenario('user query service', (getScenario) => { + + let scenario: StandardScenario + + beforeEach(() => { + // Grab the scenario before each test + // highlight-next-line + scenario = getScenario() + }) + + it('retrieves a single user for a validated user', async () => { + mockCurrentUser({ id: 123, name: 'Admin' }) + + const record = await user({ id: scenario.user.dom.id }) + + expect(record.id).toEqual(scenario.user.dom.id) + }) + + it('throws an error upon an invalid user id', async () => { + mockCurrentUser({ id: 123, name: 'Admin' }) + + const fcn = async () => await user({ id: null as unknown as number }) + + await expect(fcn).rejects.toThrow() + }) + + it('throws an error if not authenticated', async () => { + const fcn = async () => await user({ id: scenario.user.dom.id }) + + await expect(fcn).rejects.toThrow(AuthenticationError) + }) + + it('throws an error if the user is not authorized to query the user', async () => { + mockCurrentUser({ id: 999, name: 'BaseLevelUser' }) + + const fcn = async () => await user({ id: scenario.user.dom.id }) + + await expect(fcn).rejects.toThrow(ForbiddenError) + }) +}) +``` + +:::tip Using named scenarios with describeScenario + +If you have multiple scenarios, you can also use named scenario with `describeScenario` + +For example: +```js + // If we have a paymentDeclined scenario defined in the .scenario.{js,ts} file + // The second parameter is the name of the "describe" block + describeScenario('paymentDeclined', 'Retrieving details', () => { + // .... + }) +``` +::: + + + ### mockCurrentUser() on the API-side 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. diff --git a/packages/internal/src/generate/templates/api-scenarios.d.ts.template b/packages/internal/src/generate/templates/api-scenarios.d.ts.template index ca5328b0585e..8be4f6182462 100644 --- a/packages/internal/src/generate/templates/api-scenarios.d.ts.template +++ b/packages/internal/src/generate/templates/api-scenarios.d.ts.template @@ -1,9 +1,10 @@ -import type { Scenario, DefineScenario } from '@redwoodjs/testing/api' +import type { Scenario, DefineScenario, DescribeScenario } from '@redwoodjs/testing/api' declare global { /** * Note that the scenario name must match the exports in your {model}.scenarios.ts file */ const scenario: Scenario + const describeScenario: DescribeScenario const defineScenario: DefineScenario } diff --git a/packages/testing/config/jest/api/jest.setup.js b/packages/testing/config/jest/api/jest.setup.js index a77bb0ba4abe..b962ff9ea476 100644 --- a/packages/testing/config/jest/api/jest.setup.js +++ b/packages/testing/config/jest/api/jest.setup.js @@ -82,8 +82,12 @@ const getProjectDb = () => { return db } +/** + * Wraps "it" or "test", to seed and teardown the scenario after each test + * This one passes scenario data to the test function + */ const buildScenario = - (it, testPath) => + (itFunc, testPath) => (...args) => { let scenarioName, testName, testFunc @@ -96,39 +100,54 @@ const buildScenario = throw new Error('scenario() requires 2 or 3 arguments') } - return it(testName, async () => { - const path = require('path') - const testFileDir = path.parse(testPath) - // e.g. ['comments', 'test'] or ['signup', 'state', 'machine', 'test'] - const testFileNameParts = testFileDir.name.split('.') - const testFilePath = `${testFileDir.dir}/${testFileNameParts - .slice(0, testFileNameParts.length - 1) - .join('.')}.scenarios` - let allScenarios, scenario, result - - try { - allScenarios = require(testFilePath) - } catch (e) { - // ignore error if scenario file not found, otherwise re-throw - if (e.code !== 'MODULE_NOT_FOUND') { - throw e - } + return itFunc(testName, async () => { + let { scenario } = loadScenarios(testPath, scenarioName) + + const scenarioData = await seedScenario(scenario) + const result = await testFunc(scenarioData) + + if (wasDbUsed()) { + await teardown() } - if (allScenarios) { - if (allScenarios[scenarioName]) { - scenario = allScenarios[scenarioName] - } else { - throw new Error( - `UndefinedScenario: There is no scenario named "${scenarioName}" in ${testFilePath}.{js,ts}` - ) + return result + }) + } + +/** + * This creates a describe() block that will seed the scenario ONCE before all tests in the block + * Note that you need to use the getScenario() function to get the data. + */ +const buildDescribeScenario = + (describeFunc, testPath) => + (...args) => { + let scenarioName, describeBlockName, describeBlock + + if (args.length === 3) { + ;[scenarioName, describeBlockName, describeBlock] = args + } else if (args.length === 2) { + scenarioName = DEFAULT_SCENARIO + ;[describeBlockName, describeBlock] = args + } else { + throw new Error('describeScenario() requires 2 or 3 arguments') + } + + return describeFunc(describeBlockName, () => { + let scenarioData + beforeAll(async () => { + let { scenario } = loadScenarios(testPath, scenarioName) + scenarioData = await seedScenario(scenario) + }) + + afterAll(async () => { + if (wasDbUsed()) { + await teardown() } - } + }) - const scenarioData = await seedScenario(scenario) - result = await testFunc(scenarioData) + const getScenario = () => scenarioData - return result + describeBlock(getScenario) }) } @@ -189,6 +208,14 @@ const seedScenario = async (scenario) => { global.scenario = buildScenario(global.it, global.testPath) global.scenario.only = buildScenario(global.it.only, global.testPath) +global.describeScenario = buildDescribeScenario( + global.describe, + global.testPath +) +global.describeScenario.only = buildDescribeScenario( + global.describe.only, + global.testPath +) /** * @@ -261,8 +288,33 @@ afterAll(async () => { } }) -afterEach(async () => { - if (wasDbUsed()) { - await teardown() +function loadScenarios(testPath, scenarioName) { + const path = require('path') + const testFileDir = path.parse(testPath) + // e.g. ['comments', 'test'] or ['signup', 'state', 'machine', 'test'] + const testFileNameParts = testFileDir.name.split('.') + const testFilePath = `${testFileDir.dir}/${testFileNameParts + .slice(0, testFileNameParts.length - 1) + .join('.')}.scenarios` + let allScenarios, scenario + + try { + allScenarios = require(testFilePath) + } catch (e) { + // ignore error if scenario file not found, otherwise re-throw + if (e.code !== 'MODULE_NOT_FOUND') { + throw e + } } -}) + + if (allScenarios) { + if (allScenarios[scenarioName]) { + scenario = allScenarios[scenarioName] + } else { + throw new Error( + `UndefinedScenario: There is no scenario named "${scenarioName}" in ${testFilePath}.{js,ts}` + ) + } + } + return { scenario } +} diff --git a/packages/testing/src/api/scenario.ts b/packages/testing/src/api/scenario.ts index bdd5f2f63a7d..8950de3a1798 100644 --- a/packages/testing/src/api/scenario.ts +++ b/packages/testing/src/api/scenario.ts @@ -110,6 +110,10 @@ interface TestFunctionWithScenario { (scenario?: TData): Promise } +interface DescribeBlockWithGetScenario { + (getScenario?: () => TData): void +} + export interface Scenario { (title: string, testFunction: TestFunctionWithScenario): void } @@ -126,3 +130,30 @@ export interface Scenario { export interface Scenario { only: Scenario } + +export interface DescribeScenario { + ( + title: string, + describeBlock: DescribeBlockWithGetScenario + ): void +} + +export interface DescribeScenario { + ( + title: string, + describeBlock: DescribeBlockWithGetScenario + ): void +} + +// Overload for namedScenario +export interface DescribeScenario { + ( + namedScenario: string, + title: string, + describeBlock: DescribeBlockWithGetScenario + ): void +} + +export interface DescribeScenario { + only: DescribeScenario +} diff --git a/tasks/test-project/tasks.js b/tasks/test-project/tasks.js index cf9240b5534f..599f79014fe1 100644 --- a/tasks/test-project/tasks.js +++ b/tasks/test-project/tasks.js @@ -318,7 +318,7 @@ async function webTasks(outputPath, { linkWithLatestFwBuild, verbose }) { ['--force', linkWithLatestFwBuild && '--no-install'].filter( Boolean ), - execaOptions + getExecaOptions(outputPath) ) }, }, @@ -368,7 +368,7 @@ async function apiTasks(outputPath, { verbose, linkWithLatestFwBuild }) { await execa( 'yarn rw setup auth dbAuth --force --no-webauthn', [], - execaOptions + getExecaOptions(outputPath) ) // Restore postinstall script @@ -380,7 +380,7 @@ async function apiTasks(outputPath, { verbose, linkWithLatestFwBuild }) { }) if (linkWithLatestFwBuild) { - await execa('yarn rwfw project:copy', [], execaOptions) + await execa('yarn rwfw project:copy', [], getExecaOptions(outputPath)) } await execa( @@ -593,7 +593,7 @@ export default DoublePage` return execa( `yarn rw prisma migrate dev --name create_post_user`, [], - execaOptions + getExecaOptions(outputPath) ) }, }, @@ -608,7 +608,7 @@ export default DoublePage` fullPath('api/src/services/posts/posts.scenarios') ) - await execa(`yarn rwfw project:copy`, [], execaOptions) + await execa(`yarn rwfw project:copy`, [], getExecaOptions(outputPath)) }, }, { @@ -630,7 +630,7 @@ export default DoublePage` await execa( `yarn rw prisma migrate dev --name create_contact`, [], - execaOptions + getExecaOptions(outputPath) ) await generateScaffold('contacts') @@ -721,6 +721,29 @@ export default DoublePage` return createBuilder('yarn redwood g types')() }, }, + { + title: 'Add describeScenario tests', + task: async () => { + // Copy contact.scenarios.ts, because scenario tests look for the same filename + fs.copyFileSync( + fullPath('api/src/services/contacts/contacts.scenarios'), + fullPath('api/src/services/contacts/describeContacts.scenarios') + ) + + // Create describeContacts.test.ts + const describeScenarioFixture = path.join( + __dirname, + 'templates', + 'api', + 'contacts.describeScenario.test.ts.template' + ) + + fs.copyFileSync( + describeScenarioFixture, + fullPath('api/src/services/contacts/describeContacts.test') + ) + }, + }, { // This is probably more of a web side task really, but the scaffolded // pages aren't generated until we get here to the api side tasks. So diff --git a/tasks/test-project/templates/api/contacts.describeScenario.test.ts.template b/tasks/test-project/templates/api/contacts.describeScenario.test.ts.template new file mode 100644 index 000000000000..c4a8e95e9e0c --- /dev/null +++ b/tasks/test-project/templates/api/contacts.describeScenario.test.ts.template @@ -0,0 +1,55 @@ + +import { db } from 'src/lib/db' +import { + contact, + contacts, + createContact +} from './contacts' +import type { StandardScenario } from './contacts.scenarios' + +/** + * Example test for describe scenario. + * + * Note that scenario tests need a matching [name].scenarios.ts file. + */ + +describeScenario('contacts', (getScenario) => { + let scenario: StandardScenario + + beforeEach(() => { + scenario = getScenario() + }) + + it('returns all contacts', async () => { + const result = await contacts() + + expect(result.length).toEqual(Object.keys(scenario.contact).length) + }) + + it('returns a single contact', async () => { + const result = await contact({ id: scenario.contact.one.id }) + + expect(result).toEqual(scenario.contact.one) + }) + + it('creates a contact', async () => { + const result = await createContact({ + input: { name: 'Bazinga', email: 'contact@describe.scenario', message: 'Describe scenario works!' }, + }) + + expect(result.name).toEqual('Bazinga') + expect(result.email).toEqual('contact@describe.scenario') + expect(result.message).toEqual('Describe scenario works!') + }) + + it('Checking that describe scenario works', async () => { + // This test is dependent on the above test. If you used a normal scenario it would not work + const contactCreatedInAboveTest = await db.contact.findFirst({ + where: { + email: 'contact@describe.scenario' + } + }) + + expect(contactCreatedInAboveTest.message).toEqual('Describe scenario works!') + }) +}) diff --git a/tasks/test-project/tui-tasks.js b/tasks/test-project/tui-tasks.js index 9b25db21dc84..4cd136b82221 100644 --- a/tasks/test-project/tui-tasks.js +++ b/tasks/test-project/tui-tasks.js @@ -846,6 +846,29 @@ export default DoublePage` return createBuilder('yarn redwood g types')() }, }, + { + title: 'Add describeScenario tests', + task: () => { + // Copy contact.scenarios.ts, because scenario tests look for the same filename + fs.copyFileSync( + fullPath('api/src/services/contacts/contacts.scenarios'), + fullPath('api/src/services/contacts/describeContacts.scenarios') + ) + + // Create describeContacts.test.ts + const describeScenarioFixture = path.join( + __dirname, + 'templates', + 'api', + 'contacts.describeScenario.test.ts.template' + ) + + fs.copyFileSync( + describeScenarioFixture, + fullPath('api/src/services/contacts/describeContacts.test') + ) + }, + }, { // This is probably more of a web side task really, but the scaffolded // pages aren't generated until we get here to the api side tasks. So