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

Context mutations in it blocks leak into sibling describe blocks #2914

Closed
sleexyz opened this issue Jul 5, 2017 · 10 comments
Closed

Context mutations in it blocks leak into sibling describe blocks #2914

sleexyz opened this issue Jul 5, 2017 · 10 comments
Assignees

Comments

@sleexyz
Copy link

sleexyz commented Jul 5, 2017

When a describe block has inner describe blocks, as well as immediate it blocks, the this context of the immediate it blocks leak to the tests in the inner describe blocks.

  1. "tests should not leak to sibling describe blocks" - Fail (expected: Pass) ❌
describe("asdf", function () {
  describe("inner 1", function () {
    it("works", function () {
      if (this.foo) {
        throw new Error('foo should not exist');
      }
    });
  });
  it("setting foo", function () {
    this.foo = true;
  });
});

In addition, when a describe block has inner describe blocks, as well as immediate it blocks, all in the context of some parent describe block with a beforeEach, the this context of the immediate it blocks leak to the tests in the inner describe blocks, overriding the top level beforeEach!

  1. "tests should not leak to sibling describe blocks when a higher beforeEach exists" - Fail (expected: Pass) ❌
describe("asdf", function () {
  beforeEach(function () {
    this.foo = false;
  });
  describe("inner", function () {
    describe("superinner 1", function () {
      it("works", function () {
        if (this.foo) {
          throw new Error('foo should not exist');
        }
      });
    });
    it("setting foo", function () {
      this.foo = true;
    });
  });
});

Interestingly, when beforeEach is sibling to the it block and the inner describe block, mocha works as expected: the context mutation doesn't override the beforeEach.

  1. "tests should not leak to sibling describe blocks when a sibling beforeEach exists" - Pass (expected: Pass) ✅
describe("asdf", function () {
  beforeEach(function () {
    this.foo = false;
  });
  describe("inner 1", function () {
    it("works", function () {
      if (this.foo) {
        throw new Error('foo should not exist');
      }
    });
  });
  it("setting foo", function () {
    this.foo = true;
  });
});
@sleexyz sleexyz changed the title Heterogenous children of describe blocks break test isolation. Context mutations in it blocks leak into sibling describe blocks Jul 5, 2017
@ScottFreeCode
Copy link
Contributor

(TL;DR at/after the "what would be particularly helpful" line)

If I recall correct from the last time I looked into this (pun intended), Mocha goes out of its way to make this in tests and hooks behave like local variables in the suite callbacks with something equivalent to suite-level shadowing on assignment. That is, these examples should be equivalent to:

describe("asdf", function () {
  describe("inner 1", function () {
    it("works", function () {
      if (foo) {
        throw new Error('foo should not exist');
      }
    });
  });
  var foo
  it("setting foo", function () {
    foo = true;
  });
});

-

describe("asdf", function () {
  var foo
  beforeEach(function () {
    foo = false;
  });
  describe("inner", function () {
    describe("superinner 1", function () {
      it("works", function () {
        if (foo) {
          throw new Error('foo should not exist');
        }
      });
    });
    var foo
    it("setting foo", function () {
      foo = true;
    });
  });
});

-

describe("asdf", function () {
  var foo
  beforeEach(function () {
    foo = false;
  });
  describe("inner 1", function () {
    it("works", function () {
      if (foo) {
        throw new Error('foo should not exist');
      }
    });
  });
  it("setting foo", function () {
    foo = true;
  });
});

I'm not saying this is a good design -- if nothing else it seems (almost) entirely redundant given you can achieve the same behavior without this at all (except in the exports interface I guess? do people want that exact behavior in the exports interface?)... But I am saying these examples aren't necessarily bugs, unless I missed a design document specifying different behavior.


What would be particularly helpful to us is:
  • A proposed specification for a different design (beyond the level of just a few examples)
  • Discussion of what testing patterns or methodologies the proposed design enables and how difficult they are to achieve without it (e.g. can it be achieved with suite-local variables without significant downsides, can it be achieved by calling some parameterized helper functions without significant downsides, etc.)
  • Comparison to the behavior/design of this in test runners with interfaces similar to Mocha for mutual compatibility purposes (whether we'd be getting closer to or farther from similar test runners may have some influence on whether a new design is seen as worth the inevitable breakage in addition to the intrinsic merits of a given design)

(I've actually looked at a proposal earlier this year about either changing the behavior of this or adding something to achieve a slightly different behavior that has some advantageous uses, but I haven't got back around to the comparing with other test runners yet, which I wanted to do before revisiting the proposal.)

@sleexyz
Copy link
Author

sleexyz commented Jul 7, 2017

Hi @ScottFreeCode. Here's an idea of my proposed spec, of which we limit for the sake of explanation to just the semantics of beforeEach, describe, and it:

Invariants:

  • no context mutation in an it block can ever leak into another test
  • context mutations can only be done in beforeEach blocks
  • context is reset to {} on every test run.

Hypothetical implementation notes:

When a test suite is executed, each test should run its constituent code blocks in the following order:

  • (Reset the this context to {} if necessary)
  • Run the outermost beforeEach's, down to the innermost beforeEach
  • Run test
  • Run the innermost afterEach, out to the outermost afterEach

Here are some advantages from those core principles:

  • Test isolation: the status of a test should generally not depend on code in other it blocks. With the aforementioned invariants, the this context for any given it block will never be affected by other it blocks

  • Independence from the test suite: the status of a test should generally not depend on whether it is run in isolation, or as part of a test suite. With the aforementioned invariants, one will never encounter a situation where a test passes when run alone with it.only but fails when run as part of the entire test suite (assuming no use of global state or closure state).


I took a look at rspec (of which I believe mocha took much inspiration from) and transliterated the test cases I mentioned earlier. It passes all three examples, whereas mocha only passes the last example. Here is the rspec code for reference:

# 1. tests should not leak to sibling describe blocks - Pass (expected: Pass) ✔
describe "asdf" do
  describe "inner 1" do
    it "works" do
      expect(defined? @foo).to eql nil
    end
  end
  it "(mutation)" do
    @foo = true
  end
end

# 2. tests should not leak to sibling describe blocks when a higher beforeEach exists - Pass (expected: Pass) ✔
describe "asdf" do
  before(:each) do
    @foo = true
  end
  describe "inner 1" do
    describe "superinner 1" do
      it "works" do
        expect(@foo).to be true
      end
    end
    it "setting foo" do
      @foo = false
    end
  end
end

# 3. tests should not leak to sibling describe blocks when a sibling beforeEach exists - Pass (expected: Pass) ✔
describe "asdf" do
  before(:each) do
    @foo = true
  end
  describe "inner 1" do
    it "works" do
      expect(@foo).to be true
    end
  end
  it "setting foo" do
    @foo = false
  end
end

In other words, rspec guarantees test isolation if one sticks to setting up tests in beforeEach's, whereas mocha does not.

@sleexyz
Copy link
Author

sleexyz commented Jul 7, 2017

Here's another example of the test isolation I'm looking for in other testing frameworks in other languages, this time in hspec, a test runner for Haskell also very inspired by rspec.

Test should be isolated from other tests - Pass (expected: Pass) ✅

#!/usr/bin/env stack
-- stack --resolver lts-8.0 --install-ghc runghc --package hspec -- -Wall -Werror
import Test.Hspec
import Data.IORef

main :: IO ()
main = hspec $ do
  describe "asdf" $ beforeWith (\() -> newIORef False) $ do
    describe "inner" $ beforeWith (\ctx -> writeIORef ctx True >> return ctx) $ do   
      it "ctx should be true" $ \ctx -> do
        readIORef ctx >>= shouldBe True
    it "(setting ctx to False)" $ \ctx -> do
      writeIORef ctx False

In the equivalent mocha code, this test suite would fail, since somehow the context mutation in the bottom it block would leak into the inner describe block. Much like rspec, hspec does not have this issue; the beforeWith's run precisely before each test, from the outermost to innermost describe block.


rspec and hspec are both the most popular testing frameworks for their respective languages, and they both guarantee proper test isolation with respect to context setup. For the sake of programmer familiarity, mocha -- the most popular JS testing framework -- should also guarantee proper test isolation with respect to context setup.

@ScottFreeCode
Copy link
Contributor

ScottFreeCode commented Jul 8, 2017

Good to know RSpec's behavior here; thanks!

This might not be relevant in the case of Haskell with its automatic memoization and lack of mutation (and I apologize if it was mentioned already and I missed it), but does RSpec have a before(All) and after(All)? Again, I don't think it's really a good design since it's used for state that persists between tests, but it's pretty clearly a deliberate part of Mocha's design (and more widely relied upon than the exact behavior of this as far as I happen to know, although that might be anecdotal). In considering adoption of new (for Mocha; older, in general) this behavior, we'd need to decide whether this can be used in before(All) and after(All), and if so with what behavior, or alternatively whether to get rid of before(All) and after(All). (Although I suspect getting rid of them would be the most intrusive option; even if we nuked this from before(All)/after(All), as long as we kept them at all one could, for better or for worse, continue using variables that are set up just once for the suite by making them local variables of the suite callback.)

I'd also be interested in figuring out the behavior of this in the couple of JavaScript test runners that are allegedly supposed to be compatible with Mocha with few or no test code changes; RSpec, being widely imitated (on top of better designed), sets the tone for much test-driven development, but those JS test runners are where there might be direct compatibility. It's been a while since I looked into choosing JS test runners; I want to say off the top of my head that Intern and... I think maybe Jasmine? (or was it BusterJS?)... advertised having the same interface as Mocha as at least one of their options, but I can't recall for sure. I need to block out some time to glance through them all and figure out which I should focus on (and then block out some time to dig into the ones I should focus on).

@ScottFreeCode
Copy link
Contributor

One other proposal I believe we considered not too long ago was (off the top of my head; I don't recall the exact details but I think this was the gist of it) to make this in nested suites' before(All) visible to outer suites' beforeEach, e.g. so this would work:

var assert = require("assert")
var configurableType = require("./configurable-type")

describe("parameterized behavior", function() {
  before(function() {
    this.configuration = 42
  })

  beforeEach(function() {
    this.configurableObject = configurableType(this.configuration)
  })

  it("should be configured for 42", function() {
    assert.equals(this.configurableObject.configuration, 42) // imagine testing more complex behavior that depends on the object's parameter
  })

  describe("with different parameter", function() {
    before(function() {
      this.configuration = 7
    })

    it("should be configured for 7", function() {
      assert.equals(this.configurableObject.configuration, 7) // imagine testing more complex behavior that depends on the object's parameter
    })
  })
})

Or at any rate, it was some kind of proposal to allow a resource defined in an outer suite to have some kind of dependency defined alongside it in the outer suite and then overridden in an inner suite. I think it was also RSpec-inspired, although, and this is purely my fault, I spent a lot of the discussion accidentally figuring out that Mocha's current this behavior wasn't even what I had thought it was at the time, so I don't remember exactly how RSpec supposedly did it. I need to revisit that one too.

@sqrtofsaturn
Copy link

Experiencing a similar problem, having beforeEach leak across its

describe("A broken mocha", function() {
  beforeEach(function() {
    this.variable = "foo"
  })

  describe("A nested context", function() {
    beforeEach(function() {
      expect(this.variable).to.deep.equal("foo")
      this.variable = "bar"
    })

    it("should", function() {
      expect(this.variable).to.deep.equal("bar")
    })

    it("should", function() {
      expect(this.variable).to.deep.equal("bar")
    })
  })
})

Removing the nested describe causes things to work correctly.

@boneskull
Copy link
Contributor

this seems similar to some other bug I commented on the other day.

use closures instead of context object

@sleexyz
Copy link
Author

sleexyz commented Oct 6, 2017

@boneskull That is the decision Jest goes with; they remove the context object entirely.

We switched over to Jest from mocha, and initially missed mocha's context object. We then switched over to using closures, which was slightly syntactically uglier, since you'd have to first declare your variables uninitialized, and then mutate them in beforeEach's.

In any case, we found that both solutions were both lacking in terms of flow type inference, so we ended up settling on a third solution: functions.

i.e.

function setupTest (params: { url: string }): { foo: string, bar: string } {
  ...
}

it("blah", () => {
  const { foo, bar }  = setupTest({ url: "https://github.com" });
  ...
});

It's an extra line of code for each test, but you get type inference on both your inputs and outputs.
In addition, these are just functions, so you can import them, export them, compose them, etc. Closures and this are bound to their describe/it block and do not enjoy these privileges.

@ScottFreeCode
Copy link
Contributor

Can anyone weigh in on whether this issue is distinct from #2014 (see also my summary at #2014 (comment))? I've been trying to close some duplicates lately. (We may also want to see if we can make that one more likely to show up in searches related to any variation on, or interpretation of, this issue.)

@boneskull
Copy link
Contributor

@ScottFreeCode this seems to be a duplicate of #2014. closing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants