RFC: subtests, hooks, and parallel tests via Deno.Tester
API
#10771
Replies: 12 comments 18 replies
-
@lucacasonato, I like this a lot! Essentially, this would kill off Rhum and most of its functionality, but that's ok. Drash Land feels that there shouldn't be a need for a third party testing framework like Rhum just to get nesting and a different result output. We feel those features should be native to Deno. So to answer your question on how this would impact Rhum, Rhum would probably just end up being a mocking module or something -- or just completely dead and archived. |
Beta Was this translation helpful? Give feedback.
-
@lucacasonato pretty much what @crookse said! He hit the nail on the head. We created Rhum because we felt test files and test output could be ‘improved’ (in quotes because somewhat subjective to personal preference), so with your proposal, it will kill off Rhum because essentially, what Rhum offers (or at least it’s main advantages), would be part of Deno. Meaning there wouldn’t be a need for most of Rhum’s functionality and ‘Rhum test files’ can go native instead (which isn’t a bad thing). For what it’s worth, I really like your proposal! |
Beta Was this translation helpful? Give feedback.
-
I think other frameworks like jasmine have focusing a test group apply to all its children unless they are set to be ignored. That's what I did in my test_suite module, which uses Deno.test internally. The flags for a group become the default flags for all tests within that suite. At the top level, it just uses the Deno.test defaults.
I liked the idea of a TestSuite from #4092 (comment) and created a module based on that idea. In my module I made it so that a suite can be passed as the first argument to the test function or it can be included in the test definition. Then for creating subgroups you would just include the parent suite in the subgroups test suite definition. It supports sanitizeResources and sanitizeOps. I haven't added sanitizeExit but that would be easy to add. The readme has an example of grouping. https://github.com/udibo/test_suite/tree/v0.7.0#testsuite The thing I liked about the TestSuite idea was that it allows grouping without nesting. When you use the TestDefinition call signature, nesting could result in tests having a lot of indentation in front of them. Below I modified your example to use TestDefinition call signature. You can see just 3 layers in the function's body ends up having 12 spaces in front of it. /** Test with subgroups and sub tests */
Deno.test({
name:"group with subgroup",
async fn(t) {
await t.run({
name:"subgroup",
async fn(t) {
await t.run({
name:"case 1",
fn() {
// assertions here
},
});
await t.run({
name:"case 1",
fn() {
// assertions here
},
});
},
});
},
}); Another advantage of a flat TestSuite style is that if you want to add groupings later, you could do so without having to modify every line for new indentation level. You would just create the new sub testsuite then update the tests that belong to it to have it as a suite argument. I believe most people working with javascript and typescript are familiar with describe/it syntax. A flat TestSuite style of test grouping can support userland implementing that type of grouping. I added optional describe/it functions to my module that use TestSuite internally. I believe your proposal or TestSuite from the previous deno discussion I linked would make it easier for third parties to create test wrappers in the style they prefer. It would be easy to create a describe/it wrapper or a flat structured wrapper like TestSuite. The TestSuite module I created would be unaffected by this change and it could easily be modified to use Tester internally for grouping. |
Beta Was this translation helpful? Give feedback.
-
Before anything, thank you @lucacasonato to reaching out to the @drashland team for an opinion. We already discussed the current We REALLY wanted hooks + better output to be easier to deal with. We arrived at two possible outcomes for it:
To conclude, I think this proposal really fitted what we (@drashland) wanted most. The unification of testing. A personal note is that is nice that me, as a developer, don't need to worry about which testing framework is the best for Deno as I need to do in Node. It just comes out of the box. As a framework maintainer point of view, it's bad because it kills a project in which time was invested. But for me, positives outweigh the negatives (which are pretty much only pride 😄). |
Beta Was this translation helpful? Give feedback.
-
Nice, excellent elaborate write up @lucacasonato! I'm not explicitly against this yet but it brings up the question of procedural vs declarative. So far our approach has been mostly declarative, in that test definitions have been specified as objects so mixing procedural and declarative in this way might not be that great. The Go test runner is declarative all the way through, to ignore you call the In terms of handling our edge cases, to some degree this seems to introduce more than it solves. For example, we could easily figure out things like how to partition sets of tests. Like a set of tests marked as parallel safe with the same permissions can be partitioned and run in parallel based on their permission requirements. Same applies to sanitisers altho with sanitiser failure, however it will be harder for you the user to pin-point which test is leaking resources if N tests are run in parallel; so to debug you'd want to disable test case parallelism. This isn't a problem for the exit sanitiser, only applies to ops and resources. Potentially, we could be a lot smarter about tracking that but since this is global state so it's non trivial problem, being able to temporarily disable parallelism with a flag for debugging seems like a good compromise here. Filtering is also difficult with a procedural model like this, because we don't know what subtests there are until in the middle of test execution. Unlike Go we have top level execution with await, so we don't need functions like We also don't need to execute code inside a function to generate a table test, we can just execute that in the top level scope, which is going to be prettier with time as we get do let blocks. declare namespace Deno {
export interface TestDefinition {
fn: () => void | Promise<void>;
name: string;
ignore?: boolean;
parallel?: boolean,
}
export interface TestCollection {
entries: Array<TestDefinition | TestCollection>;
name: string;
}
export function test(test: TestDefinition | TestCollection);
} The basic version of Deno.test({
name: "Array.prototype.push",
entries: [
{
name: "it allows one element to be given",
fn() {
// ...
},
},
{
name: "it allows two elements to be given",
fn() {
// ...
},
},
],
]); Outputs:
Also, since we swallow the console it does probably makes sense to have some sort of context available. Haven't really thought this through yet, but maybe a console like interface. interface TestContext {
assert();
log();
debug();
// ...
} This is loosely based on TAP, which prints lines like these as "comments". |
Beta Was this translation helpful? Give feedback.
-
Nice, excellent elaborate write up @lucacasonato! |
Beta Was this translation helpful? Give feedback.
-
Hey @lucacasonato, I was just curious if the |
Beta Was this translation helpful? Give feedback.
-
How are the use cases for |
Beta Was this translation helpful? Give feedback.
-
@yacinehmito AFAIK, there are no special provisions for the |
Beta Was this translation helpful? Give feedback.
-
I'd like to address the following point on
This feels like a departure in style with a core value-proposition of Deno. This is personal, but my drive to depend on Deno as opposed to other runtimes is the well-integrated set of tools that removes the need from setting things up (TS support, dependency management, linting, formatting, testing). I don't think these tools ought to be full-fledged, but they should have just enough features to be used successfully by most projects. On that basis, I'd like to use the raw The current design effectively hampers the filtering feature. As stated in the comment, it is not reasonable for a someone using Suggestion to illustrate my point
As such, I suggest the introduction of Here is an example from the RFC, slightly modified: Deno.test("database tests", async (t) => {
const database = await setupDatabase();
await t.step("first step using database", () => {
console.log(database);
});
await t.step("second step using database", () => {});
await database.destroy();
}); Here is what it would look like with Deno.test("database tests", async (t) => {
t.beforeAll(async () => {
return {
database: await setupDatabase()
};
});
await t.step("first step using database", (t, { database }) => {
console.log(database)
});
await t.step("second step using database", () => {});
t.afterAll(async ({ database }) => {
await database.destroy();
});
}); Executing the test suite without executing the callbacks would allow building an object with all the steps, which would successfully allow filtering. This is just a suggestion, so as to be have a productive approach; I am not inviting comments on this particular suggestion, but rather on whether filtering ought to work out of the box for nested tests in Deno. Are we open to revising the design in order to support this feature out of the box? |
Beta Was this translation helpful? Give feedback.
-
Hi all, this discussion has been greatly enlightening, and I have some input. To make it more understandable, I have divided my thoughts into four sections:
Items 2 and 3 depend on item 1. 1. A declarative vs. a procedural APICurrently, our approach to tests requires users to declare them twice. Once to implement the tests themselves and then to explain their tests' structure, which is already implicit in the way tests are nested. This duplication requires a lot of effort from users and requires us to write more code on our end. By following a declarative approach, we can separate the act of declaring tests from the act of running them. My proposed API is similar to what @yacinehmito has suggested. The only difference is that it does not require users to
// Notice how the Deno.test callback doesn't need to return a promise.
// It doesn't need to return a promise because it _schedules_ tests (in other words, it _declares_ them)
Deno.test("database tests", (t) => {
// Notice how in the previous example you naturally
// used an `async` callback, not an `await` on `beforeAll`
t.beforeAll(async () => {
return {
database: await setupDatabase()
};
});
t.step("first step using database", async (t, { database }) => {
await myAsyncTask();
});
t.step("second step using database", () => { /* ... */ });
t.afterAll(async ({ database }) => {
await database.destroy();
});
}); Such separation:
2. Implementing hooks2.1 How do we implement hooks?With such an API, the I feel like we should have these explicit hooks, as they cover all the possible points when a user may want to execute setup/teardown operations. Furthermore, these functions are already widely used in the JavaScript testing community, so everyone is familiar with them. Given its adoption by people and all other testing frameworks, I believe such an interface has been an enormous success. 2.2 Should Deno even implement hooks? If so, how?In my view, it should. If Deno wants to be "a productive and secure scripting environment for the modern programmer", it must come with everything users will need to test their code. Now, everyone has a different view on what's essential. Still, I think the vast majority of people would agree that the set of crucial features includes an I think the most important aspect up for debate is not whether Deno should implement hooks, but whether it wants to have the final word on how tests should be written. A good balance between incentivizing standardization of good practices and having an open ecosystem would be to follow an approach similar to what Jest has done with These are the advantages of such an approach:
3. NamingEven though I like the That doesn't mean we necessarily need to name our APIs For example: Expected usage (ideal): describe("when buying alcoholic drinks" , () => {
describe("when buyers don't look like they're 25", () => {
it("shows a confirmation popup asking for ID", () => { /* ... */ });
});
describe("when buyers look like they're over 25", () => {
it("immediately charges the customer", () => { /* ... */ });
});
it("sends telemetry data to the central management system", () => { /* ... */ });
}); Actual usage: describe("Sales Module" , () => {
it("Confirmation popup for under 25", () => { /* ... */ });
it("Immediate checkout for over 25", () => { /* ... */ });
it("Telemetry data calls", () => { /* ... */ });
}); Considering the above, I don't think we need to name our functions My only strong opinion is that I do think that APIs for recursive grouping (meaning you can nest as many "describes" as you want) and individual tests must exist. Hooks should exist too (as per the explanation above). For hooks, given how commonplace and successful they are, I'd also advise we keep the same names (as they also evoke similar semantics in people's minds). 3.1 What about steps? + A concrete usage exampleI think that steps have a place in tests, but I think they should be used for a different purpose. In my view, steps represent an atomic part of a test. Steps cannot have Elastic's synthetics runner is a concrete example of how useful it is to have a
The Now, if you imagine that |
Beta Was this translation helpful? Give feedback.
-
@lucacasonato maybe that "polyfill" would be a good addition to deno_std/testing? The Jest/Mocha BDD style is popular in JS world, and I doubt most of the users would care all that much about the implementation as long as they get familiar looking test files, so this would be a low overhead option as compared to porting/using mocha in compat mode. |
Beta Was this translation helpful? Give feedback.
-
Users have long been asking for native support for subtests / sub steps / groups, {before/after}{Each/All} hooks, and single module parallel test execution. This proposal proposes support for all of the above in an idiomatic and very minimal API, inspired by the Go testing package.
Interface
The API being proposed revolves around a new
Deno.Tester
class. This class is not constructible by the user. Instead an instance is passed to the user as the first argument to the test function passed toDeno.test
. ThisDeno.Tester
class would have a single method calledstep
, with the same signature asDeno.test
, with the exception that it returns aPromise<bool>
indicating the result of a test. This promise never rejects. Here is what the test related type definitions would look like:Behaviours
Basic
Tests that are currently in the wild would continue to work as they do now:
The first parameter of the test function is now a
Deno.Tester
. This can be used to run sub steps:Substeps can also run in parallel, but the resource sanitizers need to be disabled (more on that below):
Tests can have subgroups, with substep too:
You can now also run setup and teardown steps before tests:
Sanitizers
Deno.test
has three sanitizers as of now. The exit sanitizer, the resource sanitizer, and the op sanitizer. These work in slightly different ways, but all work with the assumption that only one test executes at once. At the current time this is a hard limitation that is not circumventable. When wanting to use any of these sanitizers, you can not run tests in parallel (more on that later).In the new model sanitizers should be hierarchical. A test sanitizes its own function body, and all of its subtests. Each subtest gets its own sanitizer scope too. Sanitizer configuration should be inherited from the parent by default, but can be overwritten on the options. A little graph for the "database tests" example above:
Back to parallel tests: because of the parallel sanitizer limitation we have to make sure users can not run two sibling tests at once if any of them has the resource / op / exit sanitizer enabled. This means that when starting a test, if there are any sibling tests or subtests of sibling tests ongoing that either have the sanitizers enabled, or the current test has the sanitizer enabled, the test immediately fails.
Permissions
Just like with sanitizers, the same parallelization limitations apply. Only a single test can have permission sandboxing occur at a time. Two tests with different permissions can not run in parallel. This means that when starting a test, if there are any sibling tests or subtests of sibling tests ongoing that either have different permissions to the permissions requested by this test, the test immediately fails.
Just like sanitizers, permission are inherited by default but can be overwritten.
No implicit subtest await
Tests do not implicitly await for the completion of their subtests. Some users might expect subtests to be implicitly awaited before test completion, but this would make it to easy to accidentally run tests in parallel, resulting in issues with the sanitizer parallelization. Subtests not being completed at the end of the parent test means that a subtest "leaked". This should result in immediate error. All subtest have to be completed by the end of the parent test function.
Reporter output
Just like in Go, the tests would be output in hierarchical fashion with tab prefixes:
Filtering
The main downside of imperative substeps is that they can not undergo filtering without running setup and teardown steps first. This is a challenge when implementing frameworks compatible with Jest or Mocha ontop of Deno.
This is mitigated by the addition of a
stepMetadata
property on the options bag provided toDeno.test
. This optional property allows a framework (or user) to give the Deno test runner a heads up about all the steps that it will imperatively invoke.This gives the test runner enough information to provide filtering, even without having to run test blocks and their setup and teardown steps.
This
stepMetadata
is rather verbose and is not really meant to be used by users directly. It is meant to be used by frameworks which already have this information available. ThestepMetadata
does not need to be specified. If it isn't, substep filtering just doesn't work (this is no big deal).How it works
Let's say a user has this script, and runs
deno test --filter "sub step 1"
:First, tests are registered with the Deno test runner through
Deno.test
. Internally the test runner now has a list of names for all registered tests / steps.The test runner now filters the list of registered tests / steps based on the
--filter
argument. It finds "sub step 1", which is a child of "step 1", and that is a child of "group 1". This means that to run "sub step 1", the test runner will need to run "group 1", then "step 1", only then "sub step 1".The test runner will not invoke "top level test", or "step 2" (the promise that the latter returns resolves to
false
).A building block
This API would significantly improve the usefulness
deno test
, and would allow for users to build frameworks exposing functions likedescribe
,it
, orbeforeAll
/afterAll
without worrying about sanitizer issues, or messing with test output on the terminal.Beta Was this translation helpful? Give feedback.
All reactions