From ded271923611a9df5cf6376cb35939304140be30 Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Mon, 26 Dec 2022 08:56:44 +0700 Subject: [PATCH] Testing helpers are done. --- lib/testing.js | 139 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/lib/testing.js b/lib/testing.js index 6fb300a..fb34ff0 100644 --- a/lib/testing.js +++ b/lib/testing.js @@ -1,3 +1,31 @@ +/* + Helpers for the tests in `tests/` that does things like deal with + [Playwright](https://playwright.dev) setup, waiting for things in + /pages, fillng out forms, and making fake users. For a good + example of using almost everything look at `tests/ui/registration.js`. + + ### Usage + + I like to pull out each function I use separately, then set everything up like this: + + ```javascript + import {sleep, expect, tid, playstart, playstop, form, wait} from '../../lib/testing.js'; + import { base_host } from "../../lib/config.js"; + import {knex} from '../../lib/ormish.js'; + + test.before(async t => t.context = await playstart(`${base_host}/client/#/register/`)); + + test.after(async t => { + knex.destroy(); + await playstop(t.context.browser, t.context.p); + }); + ``` + + This gets `Playwright` setup to start at the right URL for the test, and makes sure that + it's stopped with `playstop`. This is important so that coverage works. You'll also + notice that I'm manually doing `knex.destroy()`. If you don't do this then your testing + runs will hang while Node waits for `knex` to exit. + */ import playwright from 'playwright'; import faker from 'faker'; import random from 'random'; @@ -12,8 +40,36 @@ const WAIT_TIME = 5000; const HEADLESS = !process.env.PLAYVIEW; const COVERAGE = process.env.NODE_V8_COVERAGE; +/* + Generates the CSS selector to match the `data-testid` for finding HTML + tags in tests. + */ export const tid = (name) => `[data-testid="${name}"]`; +/* + Fills in a form using the id="" of each field, then click + the button basedon the `tid(button_id)`. For example, here's + how the stock registration form is tested: + + ```javascript + await form(p, { + "#email": user.email, + "#full_name": user.full_name, + "#initials": "XXX", + "#password": user.password, + "#password_repeat": user.password, + "#tos_agree": true, + }, tid("register-button")); + ``` + + __FOOTGUN__: This only works with one form on the page. If you have + multiple forms then it'll probably fill in fields randomly based on + the ids. + + + `page Object` -- [Playwright](https://playwright.dev) page object. + + `fields Object` -- Mapping of "#field-id" to value to type. + + `button_id string` (___optional___) -- The name of the button to press to submit (or anything). This is passed to `tid()` so do not include the "#". + */ export const form = async (page, fields, button_id) => { for(const [key, value] of Object.entries(fields)) { if(typeof value === "boolean") { @@ -33,6 +89,28 @@ export const form = async (page, fields, button_id) => { } } +/* + Sets up a [Playwright](https://playwright.dev) instance that uses Chromium, + handles HEADLESS or not, starts at a starting URL, and sets up any coverage + requirements. + + ### Headless PLAYVIEW + + The code for whether to show the browser window or not is based on if you set + `PLAYVIEW=1` environment variables then you'll see the browser windows. This + makes debugging easier. + + ### Coverage + + Coverage is determined by whether you set `NODE_V8_COVERAGE=.coverage` environment variables. + This is specific to Node.js but that's the only thing I've tested. Setting this will + enable coverage information in the __browser__, which is important for coverage analysis + during testing. You also need to set this in the back APIs as well, but the `package.json` + has setups for this too. + + + `url string` -- The starting URL for the Playwright instance. + + `{browser, context, p: page}` -- Almost everything you need right away. + */ export const playstart = async (url) => { if(!HEADLESS) console.log("Running playwright in visible mode (not HEADLESS)."); if(COVERAGE) console.log(`Coverage reports going to ${COVERAGE}.`); @@ -48,10 +126,20 @@ export const playstart = async (url) => { p.on('console', async msg => { console.debug(`CONSOLE ${msg.type()}>>`, msg) }); + await p.goto(url); return {browser, context, p}; } +/* + Properly stops [Playwright](https://playwright.dev) if you have coverage enabled (see `playstop`). + There's a lot of things you need to do when you stop coverage, so use this or your + coverage will not actually be saved. The file is saved in the directory specified by + `NODE_V8_COVERAGE`, which is usually `.coverage`. + + + `browser Object` -- Playwright browser object from `playstart`. + + `p Object` -- Playwright page object from `playstart`. + */ export const playstop = async (browser, p) => { if(COVERAGE) { const coverage = await p.coverage.stopJSCoverage(); @@ -65,6 +153,15 @@ export const playstop = async (browser, p) => { await browser.close(); } +/* + Waits for the `css_id` selector to be visible on the page. Most external + browser controller systems are incredibly annoying because they're based on + timing and aysnc waiting for things in the page. This does all the annoying + waiting and logging an error message so you can figure out what's going on. + + + `p Object` -- Playwright page object. + + `css_id string` -- CSS ID to wait for, use `tid()` to use a `data-testid`. + */ export const wait = async (p, css_id) => { try { await p.waitForSelector(css_id, { timeout: WAIT_TIME }); @@ -74,6 +171,16 @@ export const wait = async (p, css_id) => { } } +/* + Uses the [Ava](https://github.com/avajs/ava) test variable `t` to confirm + that a tag with the given `css_id` exists. It will wait for this `css_id`, + get the tag for it, then return the `tag.textContent()`. You can then do + additional tests to make sure it contains the right content. + + + `t Object` -- Ava test object. + + `p Object` -- Playwright page object. + + `css_id string` -- Use a `tid()` to target a `data-testid`. + */ export const expect = async (t, p, css_id) => { await wait(p, css_id); @@ -83,10 +190,24 @@ export const expect = async (t, p, css_id) => { return text; } +/* + Due to the timing of requests from the browser to the backend + you sometimes have to wait for data to get stored and things to + happen. This will do that by returning a `Promise` that resolves + after a certain `ms` miliseconds. + + + `ms number` -- Miliseconds to wait, passed to `setTimeout`. + + ___return___ `Promise` -- await on this. + */ export const sleep = (ms) => { return new Promise(resolve => setTimeout(resolve, ms)); } +/* + Generate a random user for use in login forms and other things. + + + return {email, full_name, initials, password} + */ export const random_user = () => { let email = faker.internet.email(); let rnd = random.int(0, 10 * 1000); @@ -105,6 +226,14 @@ export const random_user = () => { } } +/* + Generates a `random_user` and then register it in the database. + It will add the field `user.raw_password` to the user that's returned + so you can use the password in testing. + + + `is_admin boolean (false)` -- Should this user be an admin or not? + + ___return___ `User` -- The `User` object from `lib/models.js`. + */ export const register_user = async (is_admin=false) => { const rando = random_user(); rando.password_repeat = rando.password; @@ -121,7 +250,15 @@ export const register_user = async (is_admin=false) => { return user; } -/* A helper function for other tests to use logins. */ +/* + A helper function for other tests to use logins. + + ___FOOTGUN___: It must have `email` and `raw_password` in it for this to work. The `register_user` function sets this up for you but if you want to query or craft your own `User` you'll need to ensure that. + + + `t Object` -- Ava testing object. + + `p Object` -- Playwright page object. + + `with_user User` -- A `User` object from `lib/models.js`. + */ export const login = async (t, p, with_user) => { const user = with_user ? with_user : await register_user();