diff --git a/example_tests/only/only_case.ts b/example_tests/only/only_case.ts new file mode 100644 index 00000000..0bcce9a9 --- /dev/null +++ b/example_tests/only/only_case.ts @@ -0,0 +1,29 @@ +import { Rhum } from "../../mod.ts"; + +Rhum.testPlan("test_plan_1", () => { + Rhum.testSuite("test_suite_1a", () => { + Rhum.testCase("test_case_1a1", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.testCase("test_case_1a2", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.testCase("test_case_1a3", () => { + Rhum.asserts.assertEquals(true, true); + }); + }); + + Rhum.testSuite("test_suite_1b", () => { + Rhum.testCase("test_case_1b1", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.only("test_case_1b2", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.testCase("test_case_1b3", () => { + Rhum.asserts.assertEquals(true, true); + }); + }); +}); + +Rhum.run(); \ No newline at end of file diff --git a/example_tests/only/only_suite.ts b/example_tests/only/only_suite.ts new file mode 100644 index 00000000..fbc43958 --- /dev/null +++ b/example_tests/only/only_suite.ts @@ -0,0 +1,29 @@ +import { Rhum } from "../../mod.ts"; + +Rhum.testPlan("test_plan_1", () => { + Rhum.testSuite("test_suite_1a", () => { + Rhum.testCase("test_case_1a1", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.testCase("test_case_1a2", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.testCase("test_case_1a3", () => { + Rhum.asserts.assertEquals(true, true); + }); + }); + + Rhum.only("test_suite_1b", () => { + Rhum.testCase("test_case_1b1", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.testCase("test_case_1b2", () => { + Rhum.asserts.assertEquals(true, true); + }); + Rhum.testCase("test_case_1b3", () => { + Rhum.asserts.assertEquals(true, true); + }); + }); +}); + +Rhum.run(); \ No newline at end of file diff --git a/mod.ts b/mod.ts index 0c54bad4..ec2116f2 100644 --- a/mod.ts +++ b/mod.ts @@ -76,7 +76,7 @@ export class RhumRunner { protected test_suite_in_progress = ""; - protected plan: ITestPlan = { suites: {} }; + protected plan: ITestPlan = { suites: {}, only: false, skip: false }; // FILE MARKER - METHODS - CONSTRUCTOR /////////////////////////////////////// @@ -225,9 +225,30 @@ export class RhumRunner { } } - // public only(cb: Function): void { - // // Do something - // } + /** + * Can be used for suites and cases. Will only run that. + * You can just switch a `Rhum.testSuite(...` to `Rhum,only(...` + * + * @param name - The name + * @param cb - The callback + */ + public only(name: string, cb: () => void): void { + if (this.passed_in_test_plan && this.passed_in_test_suite) { // is a test case being skipped, so do the same thing we would do in `testCase` + this.plan.suites[this.passed_in_test_suite].cases!.push({ + name, + new_name: this.formatTestCaseName(name), + testFn: cb, + only: true, + skip: false + }); + } else if (this.passed_in_test_plan) { // is a test suite being skipped, so do the same thing we could do in `testSuite` + this.passed_in_test_suite = name; + this.plan.suites![name] = {cases: [], only: true, skip: false}; + cb(); + this.passed_in_test_suite = "" // At the end of the suite, remove the name ready for a new suite. The reason for this is mainly when using `only`, so say when a 2nd suite uses `only`, it can flow through the logic properly + } + } + /** * Allows a test plan, suite, or case to be skipped when the tests run. @@ -249,9 +270,20 @@ export class RhumRunner { * }); */ public skip(name: string, cb: () => void): void { - // TODO(ebebbington|crookse) Maybe we could still call run, but pass in { - // ignore: true } which the Deno.Test will use? just so it displays ignored - // in the console + if (this.passed_in_test_plan && this.passed_in_test_suite) { // is a test case being skipped, so do the same thing we would do in `testCase` + this.plan.suites[this.passed_in_test_suite].cases!.push({ + name, + new_name: this.formatTestCaseName(name), + testFn: cb, + only: false, + skip: true + }); + } else if (this.passed_in_test_plan) { // is a test suite being skipped, so do the same thing we could do in `testSuite` + this.passed_in_test_suite = name; + this.plan.suites![name] = {cases: [], only: false, skip: true}; + cb(); + this.passed_in_test_suite = "" // At the end of the suite, remove the name ready for a new suite. The reason for this is mainly when using `only`, so say when a 2nd suite uses `only`, it can flow through the logic properly + } } /** @@ -337,6 +369,8 @@ export class RhumRunner { name, new_name: this.formatTestCaseName(name), testFn, + only: false, + skip: false }); } @@ -377,8 +411,9 @@ export class RhumRunner { */ public testSuite(name: string, testCases: () => void): void { this.passed_in_test_suite = name; - this.plan.suites![name] = { cases: [] }; + this.plan.suites![name] = { cases: [], only: false, skip: false }; testCases(); + this.passed_in_test_suite = "" // At the end of the suite, remove the name ready for a new suite. The reason for this is mainly when using `only`, so say when a 2nd suite uses `only`, it can flow through the logic properly } /** @@ -391,6 +426,33 @@ export class RhumRunner { * Rhum.run(); */ public run(): void { + // + // Validation for using `only`, mainly edge cases + // + const suitesWithOnly = Object.keys(this.plan.suites).filter(suiteName => this.plan.suites[suiteName].only === true) + const casesWithOnly: string[] = [] + for (const suite in this.plan.suites) { + const onlyCases = this.plan.suites[suite].cases!.filter(c => c.only === true); + if (onlyCases.length) { + onlyCases.forEach(c => { + casesWithOnly.push(c.name) + }) + } + } + // For when a user has set a suite and test case to only.. naughty + if (suitesWithOnly.length && casesWithOnly.length) { + throw new Error("A test suite and test case have been set to only in plan `" + this.passed_in_test_plan + ". Only one is allowed") + } + // Or when a user has two suites set to only... naughty + if (casesWithOnly.length > 1) { + throw new Error("Expected one test case as `only`, but " + casesWithOnly.length + " have been defined.") + } + // Or when a user has two cases seet to only.. naughty + if (suitesWithOnly.length > 1) { + throw new Error("Expected one test suite as `only`, but " + suitesWithOnly.length + " have been defined.") + } + + // Run them const tc = new TestCase(this.plan); tc.run(); this.deconstruct(); @@ -408,45 +470,39 @@ export class RhumRunner { * Returns the new test name for outputting purposes. */ protected formatTestCaseName(name: string): string { - let newName: string; - // (ebebbington) Unfortunately, due to the CI not correctly displaying output - // (it is all over the place and just completely unreadable as - // it doesn't play well with our control characters), we need to - // display the test output differently, based on if the tests are - // being ran inside a CI or not. Nothing will change for the current - // way of doing things, but if the tests are being ran inside a CI, - // the format would be: - // test | | ... ok (2ms) - // test | | ... ok (2ms) - // Even if plans and/or suites are the same. I believe this the best - // way we can display the output - if (Deno.env.get("CI") === "true") { - newName = - `${this.passed_in_test_plan} | ${this.passed_in_test_suite} | ${name}`; - return newName; - } - if (this.test_plan_in_progress != this.passed_in_test_plan) { - this.test_plan_in_progress = this.passed_in_test_plan; - this.test_suite_in_progress = this.passed_in_test_suite; - newName = `${"\u0008".repeat(name.length + extraChars)}` + // strip "test " - `${" ".repeat(name.length + extraChars)}` + - `\n${this.passed_in_test_plan}` + - `\n ${this.passed_in_test_suite}` + - `\n ${name} ... `; - } else { - if (this.test_suite_in_progress != this.passed_in_test_suite) { - this.test_suite_in_progress = this.passed_in_test_suite; - newName = `${"\u0008".repeat(name.length + extraChars)}` + - ` ${this.passed_in_test_suite}` + - `${" ".repeat(name.length + extraChars)}` + - `\n ${name} ... `; - } else { - newName = `${"\u0008".repeat(name.length + extraChars)}` + - ` ${name} ... `; - } - } + return `${this.passed_in_test_plan}` + "\n" + + ` ${this.passed_in_test_suite}` + "\n" + // spaces are to keep it directly below plan name plus indentation + ` ${name}`; // spaces are to keep it directly below plan name plus indentation + + // + // Description of below code + // + // Another way to display the output. It displays just how + // it usually would, but wee include Deno's `test ` text, but + // separate into it's own column to be 'out of the way'. This method + // would be desirable if Deno removed the "test " part + + // First test case for a new plan and suite + // if (this.test_plan_in_progress != this.passed_in_test_plan) { + // this.test_plan_in_progress = this.passed_in_test_plan; + // this.test_suite_in_progress = this.passed_in_test_suite; + // const newName = `| ${this.passed_in_test_plan}` + + // `\n | ${this.passed_in_test_suite}` + + // `\n | ${name}`; + // return newName; + // } + + // Case for existing plan but new suite + // if (this.test_suite_in_progress != this.passed_in_test_suite) { + // this.test_suite_in_progress = this.passed_in_test_suite; + // const newName = `| ${this.passed_in_test_suite}` + + // `\n | ${name}`; + // return newName; + // } - return newName; + // Case for existing plan and suite + // const newName = `| ${name}`; + // return newName; } /** @@ -458,7 +514,7 @@ export class RhumRunner { this.passed_in_test_plan = ""; this.test_plan_in_progress = ""; this.test_suite_in_progress = ""; - this.plan = { suites: {} }; + this.plan = { suites: {}, only: false, skip: false}; } } diff --git a/src/interfaces.ts b/src/interfaces.ts index f887fac9..f283fecd 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -72,6 +72,8 @@ export interface ITestPlan { suites: { [key: string]: ITestSuite; // "key" is the suite name }; + only: boolean; + skip: boolean; after_all_suite_hook?: () => void; after_each_suite_hook?: () => void; before_all_suite_hook?: () => void; @@ -96,6 +98,8 @@ export interface ITestPlan { */ export interface ITestSuite { cases?: ITestCase[]; + only: boolean; + skip: boolean; after_all_case_hook?: () => void; after_each_case_hook?: () => void; before_all_case_hook?: () => void; @@ -117,6 +121,8 @@ export interface ITestSuite { * argument of Deno.test(). */ export interface ITestCase { + only: boolean; + skip: boolean; name: string; new_name: string; testFn: () => void; diff --git a/src/test_case.ts b/src/test_case.ts index 1d747255..dff9e1c3 100644 --- a/src/test_case.ts +++ b/src/test_case.ts @@ -25,6 +25,7 @@ export class TestCase { if (this.plan.hasOwnProperty("suites") === false) { return; } + Object.keys(this.plan.suites).forEach((suiteName) => { // Run cases this.plan!.suites[suiteName].cases!.forEach(async (c: ITestCase) => { @@ -58,22 +59,42 @@ export class TestCase { await this.plan.after_all_suite_hook(); } }; - // (ebebbington) To stop the output of test running being horrible - // in the CI, we will only display the new name which should be - // "plan | suite " case", as opposed to the "super saiyan" - // version. This name is generated differently inside `formatTestCaseName` - // based on if the tests are being ran inside a CI job - if (Deno.env.get("CI") === "true") { - await Deno.test(c.new_name, async () => { - await hookAttachedTestFn(); - }); - } else { - await Deno.test(c.name, async () => { - Deno.stdout.writeSync(encoder.encode(c.new_name)); - await hookAttachedTestFn(); - }); + + // Because lengths of test case names vary, for example: + // + // test plan + // test suite + // test case ... ok + // another test case ... ok + // + // We are going to make sure the "... ok" parts display in a nice column, + // by getting the length of the longest test case name, and ensuring each line is a consistent length + // that would match the total length of the longest test case name (plus any extra spaces), eg + // + // test plan + // test suite + // test case ... ok + // another test case ... ok + let longestCaseNameLen = 0; + for (const s in this.plan.suites) { + const len = Math.max( + ...(this.plan.suites[s].cases!.map((c) => c.name.length)), + ); + if (len > longestCaseNameLen) longestCaseNameLen = len; } - }); - }); + const numberOfExtraSpaces = longestCaseNameLen - c.name.length; // for example, it would be 0 for when it's the test with the longest case name. It's just the character difference between the current case name and longest, telling us how many spaces to add + + const only = this.plan.only || this.plan.suites[suiteName].only || c.only + const skip = this.plan.skip || this.plan.suites[suiteName].skip || c.skip + const ignore = skip === true || only === true + await Deno.test({ + name: c.new_name + " ".repeat(numberOfExtraSpaces), + ignore: ignore, + async fn(): Promise { + await hookAttachedTestFn(); + } + }) + }) + }) } } diff --git a/tests/integration/only_test.ts b/tests/integration/only_test.ts new file mode 100644 index 00000000..98f33f9f --- /dev/null +++ b/tests/integration/only_test.ts @@ -0,0 +1,94 @@ +import { StdAsserts as asserts, colors } from "../../deps.ts"; +import {Rhum} from "../../mod.ts"; + +/** + * To be clear, we are making sure that when a user runs their tests using Bourbon, that everything works correctly, + * and the output is correct + */ + +Deno.test({ + name: "Integration | only_test.ts | Only runs the test case that has `Rhum.only`", + async fn(): Promise { + const p = await Deno.run({ + cmd: [ + "deno", + "test", + "--allow-run", + "--allow-env", + "example_tests/only/only_case.ts", + ], + stdout: "piped", + stderr: "piped", + env: {"NO_COLOR": "false"}, + }); + const status = await p.status(); + p.close(); + console.log(new TextDecoder().decode(await p.stderrOutput())) + asserts.assertEquals(status.success, true); + asserts.assertEquals(status.code, 0); + /** + * Because the timing for each test is dynamic, we can't really test it ("... ok (3ms)"), so strip all that out + */ + let stdout = new TextDecoder("utf-8") + .decode(await p.output()) + .replace(/\(\d+ms\)/g, ""); // (*ms) + /** + * There are also some odd empty lines at the end of the stdout... so we just strip those out + */ + stdout = stdout.substring(0, stdout.indexOf("filtered out") + 12); + + Rhum.asserts.assertEquals(stdout, + "running 1 tests\n" + + " \n" + + "test_plan_1\n" + + " test_suite_1b\n" + + " test_case_1b2 ... ok \n" + + "\n" + + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out " + ) + } +}) + +Deno.test({ + name: "Integration | only_test.ts | Only runs the test suite that has `Rhum.only`", + async fn(): Promise { + const p = await Deno.run({ + cmd: [ + "deno", + "test", + "--allow-run", + "--allow-env", + "example_tests/only/only_suite.ts", + ], + stdout: "piped", + stderr: "piped", + env: {"NO_COLOR": "false"}, + }); + const status = await p.status(); + p.close(); + asserts.assertEquals(status.success, true); + asserts.assertEquals(status.code, 0); + /** + * Because the timing for each test is dynamic, we can't really test it ("... ok (3ms)"), so strip all that out + */ + let stdout = new TextDecoder("utf-8") + .decode(await p.output()) + .replace(/\(\d+ms\)/g, ""); // (*ms) + /** + * There are also some odd empty lines at the end of the stdout... so we just strip those out + */ + stdout = stdout.substring(0, stdout.indexOf("filtered out") + 12); + + Rhum.asserts.assertEquals(stdout, + "running 1 tests\n" + + " \n" + + "test_plan_1\n" + + " test_suite_1b\n" + + " test_case_1b1 ... ok \n" + + " test_case_1b2 ... ok \n" + + " test_case_1b2 ... ok \n" + + "\n" + + "test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out " + ) + } +})