diff --git a/src/lib/rooibos/MockUtil.ts b/src/lib/rooibos/MockUtil.ts index 7e2acfb0..377a9ef2 100644 --- a/src/lib/rooibos/MockUtil.ts +++ b/src/lib/rooibos/MockUtil.ts @@ -126,16 +126,37 @@ export class MockUtil { gatherGlobalMethodMocks(testSuite: TestSuite) { // console.log('gathering global method mocks for testSuite', testSuite.name); + const processedFuncs = new Set(); for (let group of [...testSuite.testGroups.values()].filter((tg) => tg.isIncluded)) { + for (const hookName of [group.setupFunctionName, group.tearDownFunctionName, group.beforeEachFunctionName, group.afterEachFunctionName]) { + if (hookName) { + const key = hookName.toLowerCase(); + if (!processedFuncs.has(key)) { + this.gatherMockedFunctionByName(testSuite, hookName); + processedFuncs.add(key); + } + } + } for (let testCase of [...group.testCases].filter((tc) => tc.isIncluded)) { - this.gatherMockedGlobalMethods(testSuite, testCase); + const key = testCase.funcName.toLowerCase(); + if (!processedFuncs.has(key)) { + this.gatherMockedGlobalMethods(testSuite, testCase); + processedFuncs.add(key); + } } } } private gatherMockedGlobalMethods(testSuite: TestSuite, testCase: TestCase) { + this.gatherMockedFunctionByName(testSuite, testCase.funcName); + } + + private gatherMockedFunctionByName(testSuite: TestSuite, funcName: string) { try { - let func = testSuite.classStatement.methods.find((m) => m.name.text.toLowerCase() === testCase.funcName.toLowerCase()); + let func = testSuite.classStatement.methods.find((m) => m.name.text.toLowerCase() === funcName.toLowerCase()); + if (!func) { + return; + } func.walk(createVisitor({ ExpressionStatement: (expressionStatement, parent, owner) => { let callExpression = expressionStatement.expression as CallExpression; diff --git a/src/lib/rooibos/TestGroup.ts b/src/lib/rooibos/TestGroup.ts index 1a41d68a..07d8e5a5 100644 --- a/src/lib/rooibos/TestGroup.ts +++ b/src/lib/rooibos/TestGroup.ts @@ -29,12 +29,27 @@ export class TestGroup extends TestBlock { } public modifyAssertions(testCase: TestCase, noEarlyExit: boolean, editor: AstEditor, namespaceLookup: Map, scope: Scope) { + let func = this.testSuite.classStatement.methods.find((m) => m.name.text.toLowerCase() === testCase.funcName.toLowerCase()); + this.modifyAssertionsInFunction(func, noEarlyExit, editor, namespaceLookup, scope); + } + + public modifyAssertionsForHook(hookFuncName: string, noEarlyExit: boolean, editor: AstEditor, namespaceLookup: Map, scope: Scope) { + if (!hookFuncName) { + return; + } + let func = this.testSuite.classStatement.methods.find((m) => m.name.text.toLowerCase() === hookFuncName.toLowerCase()); + if (!func) { + return; + } + this.modifyAssertionsInFunction(func, noEarlyExit, editor, namespaceLookup, scope); + } + + private modifyAssertionsInFunction(func: any, noEarlyExit: boolean, editor: AstEditor, namespaceLookup: Map, scope: Scope) { //for each method //if assertion //wrap with if is not fail //add line number as last param try { - let func = this.testSuite.classStatement.methods.find((m) => m.name.text.toLowerCase() === testCase.funcName.toLowerCase()); func.walk(createVisitor({ ExpressionStatement: (expressionStatement, parent, owner, key) => { let callExpression = expressionStatement.expression as CallExpression; diff --git a/src/plugin.spec.ts b/src/plugin.spec.ts index 1d286dff..57b6c0b8 100644 --- a/src/plugin.spec.ts +++ b/src/plugin.spec.ts @@ -2361,6 +2361,220 @@ describe('RooibosPlugin', () => { }); }); + describe('transpilation in setup hooks', () => { + it('transpiles stubCall inside beforeEach', async () => { + program.setFile('source/test.spec.bs', ` + @suite + class ATest + @describe("groupA") + + @beforeEach + function _be() + m.stubCall(m.thing.getFunction(), "return") + end function + + @it("test1") + function _() + m.assertTrue(true) + end function + end class + `); + program.validate(); + expect(program.getDiagnostics()).to.be.empty; + expect(plugin.session.sessionInfo.testSuitesToRun).to.not.be.empty; + await builder.transpile(); + const fileContents = getContents('test.spec.brs'); + expectFunctionContents(fileContents, '__ATest_method__be', ` + m._stubCall(m.thing, "getFunction", m, "m.thing", "return") + `); + }); + + it('transpiles expectCalled inside beforeEach with early-exit guard', async () => { + program.setFile('source/test.spec.bs', ` + @suite + class ATest + @describe("groupA") + + @beforeEach + function _be() + m.expectCalled(m.thing.getFunction("arg1"), "return") + end function + + @it("test1") + function _() + m.assertTrue(true) + end function + end class + `); + program.validate(); + expect(program.getDiagnostics()).to.be.empty; + expect(plugin.session.sessionInfo.testSuitesToRun).to.not.be.empty; + await builder.transpile(); + const fileContents = getContents('test.spec.brs'); + expectFunctionContents(fileContents, '__ATest_method__be', ` + m.currentAssertLineNumber = 8 + m._expectCalled(m.thing, "getFunction", m, "m.thing", [ + "arg1" + ], "return") + if m.currentResult?.isFail = true then + m.done() + return invalid + end if + `); + }); + + it('transpiles expectNotCalled inside afterEach', async () => { + program.setFile('source/test.spec.bs', ` + @suite + class ATest + @describe("groupA") + + @afterEach + function _ae() + m.expectNotCalled(m.thing.getFunction()) + end function + + @it("test1") + function _() + m.assertTrue(true) + end function + end class + `); + program.validate(); + expect(program.getDiagnostics()).to.be.empty; + expect(plugin.session.sessionInfo.testSuitesToRun).to.not.be.empty; + await builder.transpile(); + const fileContents = getContents('test.spec.brs'); + expectFunctionContents(fileContents, '__ATest_method__ae', ` + m.currentAssertLineNumber = 8 + m._expectNotCalled(m.thing, "getFunction", m, "m.thing") + if m.currentResult?.isFail = true then + m.done() + return invalid + end if + `); + }); + + it('transpiles stubCall inside setup and tearDown', async () => { + program.setFile('source/test.spec.bs', ` + @suite + class ATest + @describe("groupA") + + @setup + function _su() + m.stubCall(m.thing.suFn(), "su-return") + end function + + @tearDown + function _td() + m.stubCall(m.thing.tdFn(), "td-return") + end function + + @it("test1") + function _() + m.assertTrue(true) + end function + end class + `); + program.validate(); + expect(program.getDiagnostics()).to.be.empty; + expect(plugin.session.sessionInfo.testSuitesToRun).to.not.be.empty; + await builder.transpile(); + const fileContents = getContents('test.spec.brs'); + expectFunctionContents(fileContents, '__ATest_method__su', ` + m._stubCall(m.thing, "suFn", m, "m.thing", "su-return") + `); + expectFunctionContents(fileContents, '__ATest_method__td', ` + m._stubCall(m.thing, "tdFn", m, "m.thing", "td-return") + `); + }); + + it('registers global stub functions referenced only from a beforeEach', async () => { + destroyProgram(); + setupProgram({ + rootDir: _rootDir, + stagingFolderPath: _stagingFolderPath, + stagingDir: _stagingFolderPath, + rooibos: { + isGlobalMethodMockingEnabled: true, + isGlobalMethodMockingEfficientMode: true + } + }); + + program.setFile('source/code.bs', ` + function globalFn() + return "real" + end function + `); + program.setFile('source/test.spec.bs', ` + @suite + class ATest + @describe("groupA") + + @beforeEach + function _be() + m.stubCall(globalFn, function() + return "stubbed" + end function) + end function + + @it("test1") + function _() + m.assertEqual(globalFn(), "stubbed") + end function + end class + `); + program.validate(); + expect(program.getDiagnostics().filter((d) => d.code !== 'RBS2213')).to.be.empty; + await builder.transpile(); + expect(plugin.session.globalStubbedMethods.has('globalfn')).to.be.true; + }); + + it('walks each hook method at most once even when reused across groups', async () => { + program.setFile('source/test.spec.bs', ` + @suite + class ATest + @describe("groupA") + + @beforeEach + function _be() + m.stubCall(m.thing.getFunction(), "return") + end function + + @it("test1") + function _() + m.assertTrue(true) + end function + + @describe("groupB") + + @beforeEach + function _be2() + m.stubCall(m.thing.getFunction(), "return") + end function + + @it("test2") + function _() + m.assertTrue(true) + end function + end class + `); + program.validate(); + expect(program.getDiagnostics()).to.be.empty; + await builder.transpile(); + const fileContents = getContents('test.spec.brs'); + // Each beforeEach should have been transpiled exactly once — if it were + // walked twice we'd see two m._stubCall(...) lines for the same call. + expectFunctionContents(fileContents, '__ATest_method__be', ` + m._stubCall(m.thing, "getFunction", m, "m.thing", "return") + `); + expectFunctionContents(fileContents, '__ATest_method__be2', ` + m._stubCall(m.thing, "getFunction", m, "m.thing", "return") + `); + }); + }); + describe('honours tags - simple tests', () => { let testSource = ` @tags("one", "two", "exclude") diff --git a/src/plugin.ts b/src/plugin.ts index fdabb837..abd7eef3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -173,8 +173,18 @@ export class RooibosPlugin implements CompilerPlugin { } const modifiedTestCases = new Set(); + const modifiedHookFunctions = new Set(); testSuite.addDataFunctions(event.editor as any); for (let group of [...testSuite.testGroups.values()].filter((tg) => tg.isIncluded)) { + for (const hookName of [group.setupFunctionName, group.tearDownFunctionName, group.beforeEachFunctionName, group.afterEachFunctionName]) { + if (hookName) { + const hookKey = group.testSuite.generatedNodeName + group.file.pkgPath + hookName.toLowerCase(); + if (!modifiedHookFunctions.has(hookKey)) { + group.modifyAssertionsForHook(hookName, noEarlyExit, event.editor as any, this.session.namespaceLookup, scope); + modifiedHookFunctions.add(hookKey); + } + } + } for (let testCase of [...group.testCases].filter((tc) => tc.isIncluded)) { let caseName = group.testSuite.generatedNodeName + group.file.pkgPath + testCase.funcName; if (!modifiedTestCases.has(caseName)) { diff --git a/test-project/src/source/HookStubs.spec.bs b/test-project/src/source/HookStubs.spec.bs new file mode 100644 index 00000000..631510ec --- /dev/null +++ b/test-project/src/source/HookStubs.spec.bs @@ -0,0 +1,130 @@ +import "pkg:/source/rooibos/BaseTestSuite.bs" +import "pkg:/source/Globals.bs" + +' Demonstrates `stubCall`, `expectCalled`, and `expectNotCalled` used inside +' setUp/tearDown/beforeEach/afterEach hooks. The bsc-plugin walks these hook +' bodies (just like test-case bodies) so the calls are rewritten to their +' runtime `_stubCall`/`_expectCalled`/`_expectNotCalled` counterparts. + +namespace tests + @suite + class HookStubsTests extends rooibos.BaseTestSuite + + private subject + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("stubCall in beforeEach") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @beforeEach + function stubInBeforeEach() + m.subject = { id: "subject" } + ' Every test in this group inherits this stub. + m.stubCall(m.subject.getName, "stubbed-name") + end function + + @it("stub from beforeEach is visible in the first test") + function _() + m.assertEqual(m.subject.getName(), "stubbed-name") + end function + + @it("stub from beforeEach is reset between tests, then reapplied") + function _() + ' The stub is re-applied for this test too, even after the runtime's + ' per-test cleanMocks/cleanStubs ran. + m.assertEqual(m.subject.getName("anything"), "stubbed-name") + end function + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("expectCalled in beforeEach") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @beforeEach + function expectInBeforeEach() + m.subject = { id: "subject" } + ' Setting the expectation up front lets every test in this group + ' just exercise the production code and rely on assertMocks(). + m.expectCalled(m.subject.doWork("payload"), "ok") + end function + + @it("test that fulfills the expectation passes") + function _() + result = m.subject.doWork("payload") + m.assertEqual(result, "ok") + end function + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("expectNotCalled in beforeEach") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @beforeEach + function expectNotCalledInBeforeEach() + m.subject = { id: "subject" } + m.expectNotCalled(m.subject.shouldNeverRun) + end function + + @it("passes when the function is never invoked") + function _() + ' Nothing calls m.subject.shouldNeverRun, so the expectNotCalled + ' set up in beforeEach is satisfied at assertMocks time. + m.assertTrue(true) + end function + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("stubCall on a global function in beforeEach") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @beforeEach + function stubGlobalInBeforeEach() + getGlobalAA().wasCalled = false + m.stubCall(globalFunctionWithReturn, function() + m.wasCalled = true + return true + end function) + end function + + @it("global stub from beforeEach is visible to the test") + function _() + m.assertTrue(globalFunctionWithReturn()) + m.assertTrue(getGlobalAA().wasCalled) + end function + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("stubCall in setUp persists across the whole group") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @setup + function stubInSetup() + ' setUp runs once per group, so this stub is reused by every test + ' below. cleanStubs() between tests will clear it; if you need the + ' stub on every test, prefer @beforeEach instead. + m.subject = { id: "subject" } + m.stubCall(m.subject.constantValue, 42) + end function + + @it("first test in group sees the setUp stub") + function _() + m.assertEqual(m.subject.constantValue(), 42) + end function + + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @describe("stubCall in afterEach is rewritten as well") + '+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + @afterEach + function stubInAfterEach() + ' Stubbing in afterEach is rare but legal — useful to make sure + ' production code touched after the test (e.g. teardown logic + ' on the SUT) sees a controlled value. + scratch = { id: "scratch" } + m.stubCall(scratch.cleanup, invalid) + scratch.cleanup() + end function + + @it("runs a normal assertion; afterEach uses stubCall behind the scenes") + function _() + m.assertTrue(true) + end function + + end class +end namespace