-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
"Component-level" tests of prerender_component()
.
#36
Comments
Refs #36. This prerenders a `site-counter` component and then loads it in Puppeteer based the second strategy suggested in #36 (comment). This requires quite a bit of boilerplate but is relatively intuitive and mostly works out of the box. It does rely on testing helpers private to `rules_prerender` which real users would not be able to take advantage of (`useDevserver()`, `usePuppeteer()`, `resolveRunfile()`). Regardless, it tests the critical integration point of JavaScript running on prerendered HTML, verifying that the content is hydrated and used correctly. This does include the known downside of using separate files for generating test cases and actually running the tests. It requires a bit of unfortunate back and forth, but isn't too bad for a simple counter at least. Some helpers could be introduced to reduce the boilerplate, such as a `generateTestCase('Zero', () => `<site-counter>...</site-counter>`)` which could generate all the HTML scaffolding for the page with a given title and body content. `useDevserver()` and `usePuppeteer()` could be turned into public APIs (maybe less opinionated though). One unfortunate limitation of the current approach is that the page hydrates too fast for Puppeteer to inspect the browser DOM. We could drop the prerender unit test and merge it into this if it were possible teo inspect the component DOM before it hydrates. It would allow us to easily test that the component prerendered to a good state (initial value visible, buttons disabled, etc.) before testing the loaded JavaScript. Again, some more opinionated test helpers might be able to defer JS execution to make this possible.
In 580419d, I attempted the second strategy described above and it actually worked out ok. The biggest negative is definitely that the test cases and test script are two different files. Beyond that, there is definitely a lot of boilerplate, but some more focused test helpers could probably cut it down quite a bit. Unfortunately Puppeteer loads too fast to assert the DOM before the JS is executed and the component is hydrated. I'd love to find a good way to assert on the prerendered content by itself, that would allow users to drop the separate unit test of the prerender output in many cases. One other thought I had was to use Karma instead of Puppeteer for testing. This would likely run faster and give more convenient access to the DOM. However, I don't believe Karma has a good story for navigating to a real HTML page and then testing it. I believe Karma generally works via its own test HTML page which then loads user JavaScript onto it. There are maybe some workarounds such as parsing the test cases and stamping them to the DOM in Karma, with each test case embedded in a |
prerender_component()
.prerender_component()
.
4ce3227 has reaffirmed my interest in more improvements to the testing story. I think with HydroActive being a key part of the client-side JavaScript story, it would be worth more thoroughly investigating. Following up from my previous comment with a more fleshed-out design and in light of Karma's deprecation I think the solution look something like this: // component.prerender.tsx
import { includeScript, polyfillDeclarativeShadowDom } from '@rules_prerender/declarative_shadow_dom';
import { Template } from '@rules_prerender/preact';
import { VNode } from 'preact';
export function MyComponentPrerender({ initial = 5 }: { initial?: number }): VNode {
return <my-component>
<Template shadowrootmode="open">
{polyfillDeclarativeShadowDom()}
{includeScript('./my_component.script.mjs', import.meta)}
<div>The count is: <span>{initial}</span>.</div>
<button>Increment</button>
</Template>
</my-component>;
} // component.script.mts
import { component } from 'hydroactive';
export const MyComponent = component('my-component', ($) => {
const [ count, setCount ] = $.live('span', Number);
$.listen($.query('button'), 'click', () => { setCount(count() + 1); });
});
declare global {
interface HTMLElementTagNameMap {
'my-component': MyComponent;
}
} // my_component.test.tsx
import { testCase, getEl } from '@rules_prerender/mocha';
import { renderToHtml } from '@rules_prerender/preact';
import { hydrate } from 'hydroactive';
import { MyComponentPrerender} from './component.prerender.js';
import { MyComponent } from './component.script.mjs';
describe('my_component', () => {
describe('MyComponent', () => {
describe('default initial value', () => {
// Render a test case for this describe block, wraps the output in an HTML page and sets `defer-hydration` automatically.
testCase((): SafeHtml => renderToHtml(<MyComponentPrerender />));
it('prerenders an initial value of 5', () => {
// Gets the prerendered element from the `testCase`.
const el = getEl();
expect(el.shadowRoot!.querySelector('span')!.textContent).toBe('5');
});
it('increments on click', () => {
// Gets the prerendered element from the `testCase`.
const el = getEl();
// Hydrate the component.
hydrate(el, MyComponent);
// Hydration does not alter the content.
const count = el.shadowRoot!.querySelector('span')!;
expect(count.textContent).toBe('5');
// Clicking the button increments the count.
el.shadowRoot!.querySelector('button')!.click();
expect(count.textContent).toBe('6');
});
});
});
}); # BUILD.bazel
load("@rules_prerender//:index.bzl", "prerender_component", "prerender_component_test_cases")
load("@aspect_rules_jasmine//jasmine:defs.bzl", "jasmine_node_test")
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
load("@web_test_runner//wtr:defs.bzl", "wtr_test")
prerender_component(
name = "component",
prerender = ":prerender",
scripts = ":scripts",
)
ts_project(
name = "prerender",
srcs = ["component.prerender.tsx"],
deps = [
"//:node_modules/@rules_prerender/preact",
"//:node_modules/preact",
],
)
ts_project(
name = "scripts",
srcs = ["component.script.mts"],
tsconfig = "//:tsconfig_client",
deps = ["//:node_modules/hydroactive"],
)
ts_project(
name = "component_test_lib",
srcs = ["component.test.tsx"],
tsconfig = "//:tsconfig_client",
testonly = True,
deps = [
":prerender",
"//:node_modules/@rules_prerender/preact",
"//:node_modules/@types/jasmine",
"//:node_modules/@types/jasmine",
],
)
# Compiles and executes `component.test.tsx`, but skips all the `it` blocks. Only runs `testCase` calls and collects
# their outputs. Each test case gets wrapped in an HTML page with the result in a `<template>` element which can
# be cloned for each test. Also includes `<script>` tags to execute the associated test code at runtime.
prerender_test_cases(
name = "component_test_cases",
deps = [":component_test_lib"],
)
# Compiles and executes `component.test.tsx` _again_. This time however `testCase` isn't actually invoked. Instead, each
# prerendered HTML test case is executed to run the actual tests, filtered to the associated `it` blocks for each `testCase`.
wtr_test(
name = "component_test".
data = [":component_test_cases"],
deps = [":component_test_lib"],
) This works by having a single <!DOCTYPE html>
<html>
<head>
<title>default initial value</title>
<meta charset="utf8">
<!-- The component is rendered inside a template, so it can be cloned for each test. -->
<template id="component">
<!-- `defer-hydration` is automatically applied to the top-level rendered custom element. -->
<my-component defer-hydration>
</my-component>
</template>
<!-- Includes bundled test script. -->
<script src="./component.test.js" type="module"></script>
</head>
<body></body>
</html> Because this render happens at build-time, we can reuse const template = document.head.querySelector('template#component')!;
let instance: HTMLElement | undefined;
beforeEach(() => {
instance = template.content.cloneNode(true /* deep */);
document.body.appendChild(instance);
});
afterEach(() => {
instance.remove();
instance = undefined;
}); With this, each
But there are some costs:
I could potentially support another API which could make a separate I'll need to think on this more and experiment. Maybe what I really want is to run browser-level tests on the prerendered components, and that's something which can happen even with a separate |
In #10 we added a simple
:%{component}_prerender_for_test
target which allows test code to directly depend on the prerender library to verify that it works as expected. This is great for small unit tests, but unfortunately is not usable for testing interactions between the prerendered HTML and the loaded client-side JS or CSS. It also doesn't help to test that the required resources are present. A few example testing use cases which are not fulfilled here include:All of these revolve around testing a component by prerendering it to a real page, bundling that page, then loading it into a real browser, and asserting on the page. We could conceivably make a
prerender_component_test()
rule which does all this work and gives a nice API for users to prerender some HTML and then use a browser driver to load that page and assert on it.The ideal API for users might look something like this:
Unfortunately this is quite involved to actually build. For starters it has to generate a
renderMyComponentHarness()
function which is roughly equivalent to theprerender_page()
Starlark macro except executed entirely at runtime. As a result, this would require running the entire prerendering pipeline at runtime within a test. This includes executing the renderer to output the prerendered HTML, extracting all the JS and CSS sources, generating an entry point for each, invoking Rollup/PostCSS to bundle them together, inject those files into the resulting HTML, and then copying everything into a directory (or an in-memory object). Even once that is done, we need to implementstartServer()
which is effectively equivalent toweb_resources_devserver()
(ironically this basically already exists asuseDevserver()
internally, it just requires a pre-built server). This is a very confusing workflow and relies on a lot of magic in the test harness.On the bright side I can easily avoid a dependency on any particular test framework or browser driver. If a user wanted to rewrite this test with Jest and Playwright they could do so easily. Arguably I could defer the server implementation to the user as well, but I don't think that is as practical to get away with. If we accept a hard dependency on the test framework, then we could simplify
prerender_component_harness()
toprerender_component_test()
, which would do the same thing under the hood, but simply accept test sources as an input. Unfortunately we couldn't delegate that to a user controlledts_library()
because it needs a dependency on the harness.As an alternative, we could leverage the existing build system to prerender the test pages at build time and then simply assert on them in the test at runtime. This might look like:
This strategy has the user explicitly generating test cases at build time using the existing
prerender_pages()
rule, which already handles rendering and bundling a site. I think this is cognitively much simpler because it uses infrastructure which the user is already familiar with rather than a bunch of extra magic. We also don't need to maintain a separate toolchain to replicate the behavior ofprerender_pages()
entirely at runtime. It also means less opportunity for divergence between the real production build and the tests being executed.The biggest downside here is that the code which actually prerenders a page and the code which tests it are in two different files. This is a bit frustrating and makes it harder to correlate the two together. Unfortunately there is no great way to merge these two pieces into one file and doing so would probably make the system more complicated rather than less.
One interesting aspect with this strategy is that there actually isn't much work to do to support this. The only symbol which doesn't exist today is
startServer()
, and that could easily be implemented by the user, there's nothingrules_prerender
specific there. In fact, we could probably leverage some existing library to provide that function out of the box. Users could also provide their own server if they have some special server-side functionality their application depends upon. Everything else is directly depended upon by user code, notrules_prerender
. So this still supports Jest and Playwright for example.I definitely like option 2. a lot better. It's less magical, less prone to divergence from production, and requires less infrastructure. The tricky part is documenting all this and explaining it to users. I'll also need to find (or implement) a usable server implementation for this purpose.
I think the action item here is to really just try out option 2. since it requires so little up front effort to support. If it works out well enough, then the biggest task here is just documenting and codifying the approach for real projects to use.
The text was updated successfully, but these errors were encountered: