Skip to content
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

Core: Run before and after hooks with module context #1762

Conversation

raycohen
Copy link
Member

@raycohen raycohen commented Jun 9, 2024

The before and after hooks run once per module as long as there is at least one test in the module. Using environment inheritance allows us to use the module context in those hooks, which allows reading the expected changes to the context from a before hook inside nested modules.

This is a breaking change from 2.x behavior. Previously the after hook ran with the last test's context instead of the module context, which allowed it to access things set on this from inside the test. That will no longer be accessible, and some of the tests were relying on that behavior, so I had to refactor them to get that information from variables in scope for both the test and after hook instead.

This PR replaces #1559

Fixes #1328.
Ref #869.

@raycohen raycohen added this to the 3.0 release milestone Jun 9, 2024
@raycohen raycohen marked this pull request as ready for review June 9, 2024 19:18
@raycohen raycohen marked this pull request as draft June 9, 2024 19:22
test/main/modules.js Outdated Show resolved Hide resolved
src/module.js Show resolved Hide resolved
@raycohen raycohen marked this pull request as ready for review June 9, 2024 19:32
src/test.js Outdated Show resolved Hide resolved
src/test.js Outdated Show resolved Hide resolved
@raycohen raycohen force-pushed the before-and-after-hooks-use-module-environment-instead-of-test-environment-2024 branch from cce3272 to b28e8b2 Compare June 14, 2024 02:17
src/test.js Show resolved Hide resolved
src/test.js Show resolved Hide resolved
Krinkle added a commit to Krinkle/qunit that referenced this pull request Jun 19, 2024
This should make it significantly more robust and easier to see
what's actually going on, primarily to help review
qunitjs#1762.
@Krinkle
Copy link
Member

Krinkle commented Jun 19, 2024

(continuing in main-thread, responding to comment about this.testEnvironment = extend({}, module.testEnvironment);)

@raycohen Yeah, read-only could be a compromise here.

So what we have today:

  • test.before() fires moduleStart + testStart event callbacks, which feel "outside" the test execution. Not given assert or this. But, can technically access it, albeit with the now-known strange and surprising behaviours in various cases, does broadly work fine in practice, but definitely not good enough, not up to our standard.
  • we perform execution: before hooks, test, after hooks.
  • test.finish() fires moduleDone + testEnd event callbacks.

I see a couple of options:

  1. Keep clean separation of events and execution. Remove remnant this.testEnvironment.
  • QUnit.config.current.testEnvironment becomes undefined during moduleStart/testStart.
  • We'll later document as footnote in QUnit 3 guide to use QUnit.hooks.before/beforeEach() for this purpose instead.
  1. Keep separation. Remove mutation and keep limited read-only compat.
  • give events a fresh throw-away copy of the module env (start) or last test (end), similar to what we do around each test.
  • Between moduleStart+testStart and hooks.before, we will create a new copy of the module env, this time mutable.
  • Then, between hooks.before and hooks.beforeEach/test.run/etc we create another throw-away copy, like today.
  1. Interweave events and execution. This would offer clear and clean ordering with regards creating the module env, then copying to run the test, and restoring afterward. No lost writes. No time-travelling.
  • Today:
    • event moduleStart
    • event testStart
    • hook before
    • hook beforeEach
    • test run
    • hook afterEach
    • hook after
    • event testEnd
    • event moduleDone
  • Instead:
    • event moduleStart (testEnv=module env)
    • 🔺 hook before (testEnv=module env)
    • 🔻 event testStart (testEnv=test env)
    • hook beforeEach
    • test run
    • hook afterEach
    • 🔺 event testEnd (testEnv=test env)
    • 🔺 hook after (testEnv=module env)
    • event moduleDone

Summary:

  1. Pro: Unchanged order. Intuitive outcome (no surprises/dead-ends). Change is easy to explain. Use hooks for env. Con: Breaks env in events.
  2. Pro: Unchanged order. Unchanged support for read-only env in events. Cons: Break env writes in events. Change subtle/difficult to explain (only read-only is permitted). Writes are silently lost (surprising). Content of the env object either continues to be under-specified in edge cases and/or will undergo the same major change as for begin/after hooks. Continue time-travelling between "test" start and "module" hooks.
  3. Pro: Unchanged support for event in events (read-write). Intuitive outcome for env lifetime and ordering (no lost writes, no time-travelling.). Neutral: Different execution order, arguably clearer in some ways. Cons: Breaks execution order. Encourages mixing of observing and runtime. Leaves odd entrypoint for writing to module env from current.testEnv, which if we improve that, we might as well go with (1) and recommend use of global hooks.

Krinkle added a commit to Krinkle/qunit that referenced this pull request Jun 19, 2024
This should make it significantly more robust and easier to see
what's actually going on, primarily to help review
qunitjs#1762.
Krinkle added a commit that referenced this pull request Jun 19, 2024
This should make it significantly more robust and easier to see
what's actually going on, primarily to help review
#1762.
@raycohen raycohen force-pushed the before-and-after-hooks-use-module-environment-instead-of-test-environment-2024 branch from 8692e50 to 1cea733 Compare June 21, 2024 17:44
test/main/modules.js Outdated Show resolved Hide resolved
@raycohen raycohen marked this pull request as draft June 21, 2024 17:50
src/test.js Outdated Show resolved Hide resolved
'child-after: before=PC beforeEach=PC tester=2 afterEach=CP',
'parent-after: before=PC beforeEach=PC tester=2 afterEach=CP after=C'
'child-after: before=PC',
'parent-after: before=P'
Copy link
Member Author

@raycohen raycohen Jun 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the parent after hook can't see that this.after was set to C in the child's after hook, because the assignment becomes one of the child module.testEnvironment's own properties.

Similarly, it doesn't see the C in this.before, even though all child checks do.

@raycohen raycohen marked this pull request as ready for review June 21, 2024 18:43
@raycohen
Copy link
Member Author

raycohen commented Jun 21, 2024

Neutral: Different execution order, arguably clearer in some ways.

I would say my initial impression is that it seems odd to include the before and after hooks inside the testStart/testEnd events, so the changed order you have seems clearer or less surprising to me.

It does seem like open 1 is the simplest to explain. Without being aware of any use cases for reading and writing there, I might vote for that. It seems like there's a refactor available that likely gets anyone using it for that a way forward?

@Krinkle
Copy link
Member

Krinkle commented Jun 22, 2024

@raycohen Yeah, same here. By removing env, we remove importance of the other, and when all else is equal I'd rather not change the ordering there to avoid suble changes in external state in people's code. Sounds like option 1 is a good way forward then!

Krinkle added a commit that referenced this pull request Jun 23, 2024
Use separate properties to be able to distinguish between own and
inherited ones. When "appending" to a property, it effectively copies
it do the own object, making it hard to notice whether the properties
of a parent module really are copied/preserved at each step, vs only
visible at step 1 where it copies it thus masking later bugs where
we might forget to extend/mixin the parent env.

Also add a test for the module scope callback's context object.

Ref #1762.
Krinkle added a commit that referenced this pull request Jun 23, 2024
Use separate properties to be able to distinguish between own and
inherited ones. When "appending" to a property, it effectively copies
it do the own object, making it hard to notice whether the properties
of a parent module really are copied/preserved at each step, vs only
visible at step 1 where it copies it thus masking later bugs where
we might forget to extend/mixin the parent env.

Also add a test for the module scope callback's context object.

Ref #1762.
The before and after hooks run once per module as long as there is at least one
test in the module. Using environment inheritance allows us to use the module
context in those hooks, which allows reading the expected changes to the
context from a before hook inside nested modules.

Once before hooks have run, create a flattened deep copy of the module
testEnvironment and assign that to test testEnvironment. At this point nothing
should use test.testEnvironment until the before hooks have run.

Fixes qunitjs#1328.
Ref qunitjs#869.
@Krinkle Krinkle force-pushed the before-and-after-hooks-use-module-environment-instead-of-test-environment-2024 branch from 759a391 to 75469e5 Compare June 23, 2024 23:52
@Krinkle
Copy link
Member

Krinkle commented Jun 23, 2024

I'm slowly comin around around to embracing Object.create() inheritence.

I was pondering if its worth it and possible to avoid this between modules (in the same way we avoid it between module and test. In QUnit 2.x, all context props are owned by the this object, making them visible to Object.keys(this) and filtered for-loops, with consistent behaviour across module hooks, test hooks, and tests.

This patch I believe preserves that for tests, per-test hooks, and non-nested module hooks. But, for nested child modules, the "before" hook would now see a this that has prototypal inheritence to the parent context. I was focussing on how we set up the module hook's env, but completely forgot that we first create env during the module() function, which happens during the registration phase, not during execution. This starts out with props from the options parameter. Copying the parent props to this synchronously, like we do today, is exactly what causes the bug we're trying to solve. It's doing it too early. Inheritence naturally solves that by allowing continous later modification from underneath it.

I briefly considered if we could "park" these options and defer env creation until the test execution phase begins (e.g. at the "before" hook). However that would mean this in the module scope callback lacks parent properties set by the same mechanism, which is a breaking change.

QUnit.module('parent', { foo: 1 }, function () {
	this.foo++;
	QUnit.module('child', { bar: 1 }, function () {
		// sees this.foo=2 and this.bar=1
		var foo = this.foo;
		var bar = this.bar;
		QUnit.test('example', function (assert) {
			assert.equal(foo, 2, 'foo');
			assert.equal(bar, 1, 'bar');
		});
	});
});

I'll cover this with a test, but it shows there's a need to create the env object earlier. The only way to create it correctly and use that same object throughout, is with inheritence. We can't realistically defer env creation, because the callback needs something usable right away. If we really wanted to avoid this behaviour change, we could go deeper and flatten it like today, and also park it for later, and re-create it. But, I think that trades one set of surprises for another.

Given we're doing a semver-major, I'd say let's do this more cleanly as you have it, and document that anything hasOwn-filtering for loops can safely stop doing so. We already guruantee env to be a plain object by copying props. The downside is that convenience functions like Object.keys() can't be used. By comparison, jQuery.each() and jQuery.map() don't filter non-own properties.

I guess the remaining question then is: What level of consistency do we want to go for?

  1. Current patch: Both parent and child props are always owned, except during module scope callback, child before hook. and child after hook. The parent props are flattened and owned in beforeEach, test, and afterEach.

    Note that if the property contains a primitive value and is "modified" by child before hook, then that read-write operation (e.g. +=) effectively copies it into the own object, thus unless our assertion checks for separate properties, this is hard to observe. I've fixed this in Test: Add more tests for test context in module scope and inheritence #1770.

  2. Alternative: Always use inheritence for parent module. Each test env is created by using Object.create() from the parent module env, and then extend() to mixin the module's own env. This giving the appearance that within the current module, we always operate on own properties both. Both in before/after hooks at the module level, in test hooks, and in tests. This would mean that Object.keys() will consistently not see properties inherited from parent modules, in before/after hooks (like option 1), and beforeEach/test/afterEach alike.

  3. Alternative: Use inheritence for everything, including between current module and current test. Ech test env is created by using Object.create() from the current module env. This would mean that properties created for the current module's tests by before hooks would become inherited properties during test hooks and tests.

  4. Alternative: We treat the observable inheritence as a bug. Before the execution begins (at the "before" hook) we flatten the module env and disconnect from live inheritence. In this case, all code consistently sees own properties only, with the exception of the module scope callback. This has the other difference of making late writes from any badly written async parenet module hooks fail / ignored, same as today.

@raycohen What do you think?

Option 3 feels the purest, but also the largest change. Could a use case benefit from this?

I lean towards Option 1 (easy to explain: inheritance exists pre-test, each test clones the current module env; the module env can be seen as a template for the test env), or Option 4 (keep inheritence internal like it is today).

@raycohen
Copy link
Member Author

raycohen commented Jun 24, 2024

I think I prefer 1 over 4 because having matching behavior for Object.keys(this) in the module callback and in the before hook seems desirable.

For 3 I like the implementation but I can't think of any usage-based argument for it. Why would user code care which parent module had set up a given env value? If we go with flattening, a user could manually access that info if they needed, e.g

module('parent', function () {
  this.foo = 1;

  module('child', { parentEnv: this }, function () {
    this.hasOwnProperty('foo') // false
    this.parentEnv.hasOwnProperty('foo') // true

    module('deeper child', { parentEnv: this }, function () {
      this.parentEnv.parentEnv.hasOwnProperty('foo') // true
    });
  });
});

We could even (in the future, if we realized a compelling use for it) automatically set up a parentEnv property as a part of each module's own env. So I don't think we lose much by not doing inheritance at the test level as well.

Alternative 2 is interesting but if the goal is behavior the user expects I think Options 1 and 4 are probably better.

So, I'm also leaning towards Option 1

@Krinkle Krinkle merged commit 03b764d into qunitjs:main Jun 25, 2024
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Share 'before' hook test environment with child module
2 participants