From cec3d78a4abb7060a51011009cb3196055c491eb Mon Sep 17 00:00:00 2001 From: s-blu Date: Mon, 13 Jan 2025 12:03:01 +0100 Subject: [PATCH 1/8] Replace stub for adding tests with a guide for jest --- ...w to add automated tests to your plugin.md | 392 +++++++++++++++++- 1 file changed, 388 insertions(+), 4 deletions(-) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index cdbe51c05..da1af8f58 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -1,8 +1,7 @@ --- aliases: -- tags: -- seedling + - evergreen publish: true --- @@ -10,9 +9,394 @@ publish: true %% Add a description below this line. It doesn't need to be long: one or two sentences should be a good start. %% -#placeholder/description +This page will guide you through integrating [jest](https://jestjs.io/docs/getting-started) as a testing framework to the obsidian example plugin to enable automated testing for your plugin. Furthermore, the guide will cover specialties when running tests using the obsidian API. + +This guide will use the obsidian example plugin as a showcase. It also assumes knowledge about unit testing to a certain degree. If you wish to go through the guide step-by-step, please download and enable the example plugin like described in [Build a plugin](https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin). + +This guide focused on how to integrate jest to your plugin and what steps to take to deal with the Obsidian API when running plugin code in a test environment. It does not provide guidance how to write test cases or what good tests are. Please refer to [[Plugin Testing for Developers]] for a deeper look at these topics as well as [[How to find examples of Jest-based plugin tests]] for examples. +## Integrate jest into obsidian-example-plugin + +Integrating jest to a project and make it compatible with typescript code requires some additional dependencies. + +1. Open up a terminal and navigate to your plugins root folder +2. Install jest via `npm install --save-dev jest` +3. Install types for correct typing in typescript files: `npm install --save-dev @types/jest` +4. Install ts-jest to transpile typescript files: `npm install --save-dev ts-jest` +5. Create a jest configuration file by running `npx ts-jest config:init` +6. Open `tsconfig.json` and add `"esModuleInterop": true` as an additional property to `compilerOptions` +7. Open `package.json` and add `"test": "jest"` to the `scripts` section + +jest is now integrated into the project and usable for testing. + +## Executing a first automated test + +> [!hint] Structuring with folders +> For reason of simplicity, this guide adds files to the root folder. Generally, it is a good idea to structure your code in folders, i.e. productive code in `src` and tests in `tests` to keep your code base better maintainable. + +1. Create a new file named `example.spec.ts` to your projects root folder + +> [!info] Test file names +> It is not required to call tests with a `.spec.ts` ending, `.ts` is enough. However, it help you to see at one glance which file contain productive code and which are test files, which will help with development and maintainability. + +2. In `example.spec.ts`, add a `describe` block. Read more about describe blocks in [jests documentation](https://jestjs.io/docs/api#describename-fn). Basically, a `describe` bundles up multiple test cases for better readability. + +```ts +// example.spec.ts +describe('MyPlugin Tests', () => { + +}) +``` + +3. To the describe, add a test case. Please note that `it` and `test` are aliases, so you can also call `test`, depending on your preference. Read more in [jests documentation](https://jestjs.io/docs/api#testname-fn-timeout) + +```ts +// example.spec.ts +describe('MyPlugin Tests', () => { + +  it('should be able to execute test', () => { +    expect("hello").toBeTruthy(); +  }) +}) + +// this is necessary to conform the isolatedModules compiler option and can be removed as soon as an import is added +export {} +``` + +4. To execute the test, open a terminal and execute `npm run test`. This triggers the script you've added to the package.json and should execute jest, showing you a successful test run. +### Testing plugin code + +The previous test case made sure that jest is correctly integrated to the project. In general, testing a static value does not hold much value. The goal of automated testing is to runs code and ensure that it meets certain expectations. In our case, we want to test plugin related code. + +Unfortunately, Obsidian API is not straight forward to test. Obsidian functionality is only available if the code runs in context of an Obsidian app and will not be available when running code in our testing environment. The obsidian node module contains types only. This means jest will fail to find and execute i.e. classes and functions. It will work fine for types. + +First of all, exchange the static test case from before with one using the plugin code. Replace your test file content with: + +```ts +// example.spec.ts +import MyPlugin from "./main"; + +describe("MyPlugin Tests", () => { + +  it("should be importable", () => { +    expect(MyPlugin).toBeTruthy(); +  }); +}); +``` + +Run `npm run test`. It'll fail with an `Cannot find module 'obsidian' from 'main.ts'` error due to the reasons explained above. + +There are multiple ways working around this error and this guide will detail two of them. They're not exclusive. Depending on the scope of your plugin it might be worthwhile to combine both approaches. +#### Mocking Obsidian API + +jest carries functionality to mock a variety of things, including dependencies. In case of the Obsidian API, a [manual ES6 class mock](https://jestjs.io/docs/es6-class-mocks#manual-mock) is required. + +Mocking the Obsidian API is uncritical in sense of testing your code. When testing, you will need to assume that any third party dependency, including the Obsidian API, is working like expected and concentrate testing only code under your own responsibility. Thus, mocking the Obsidian API does not reduce the value of automated tests. + +1. Create a folder named `__mocks__` at the root of your plugin folder +2. In `__mocks__`, create a file called `obsidian.ts` and provide an empty (mock) class for every import listed at the top of `main.ts`: +```ts +export class Modal {} +export class Notice {} +export class Plugin {} +export class PluginSettingTab {} +export class Setting {} + +// mocking these classes is not necessary. They're part of the import but only accessed as types, which should work out of the box. +// export class App {} +// export class MarkdownView {} +// export class Editor {} +``` +3. Run `npm run test` again and your test should succeed. + +This mock is only enough to enable jest to read `main.ts` at all. It is insufficient for other scenarios. +For example, add a second test case below the existing one: + +```ts + // example.spec.ts + it('onload should load default settings', async () => { + const plugin = new MyPlugin({} as any, {} as any); + await plugin.onload(); + + expect(plugin.settings).toEqual({ + mySetting: 'default' + }) + }) +``` + +Run it with `npm run test`. It'll fail due to a `TypeError: this.loadData is not a function` error. +`loadData` is provided by the `Plugin` class `MyPlugin` is extending from, but our mock is an empty class and not providing such a function. To have the `onload()` function running successfully, we need to provide mocks for every accessed function from the Obsidian API. Enhance `__mocks__/obsidian.ts` like follows: + +```ts +export class Modal {} +export class Notice {} +export class Plugin { + loadData() {} + saveData() {} + addRibbonIcon() { + return { + addClass: () => {} + } + } + addStatusBarItem() { + return { + setText: () => {} + } + } + addCommand() {} + addSettingTab() {} + registerDomEvent() {} + registerInterval() {} +} +export class PluginSettingTab {} +export class Setting {} + +// mocking these classes is not necessary. They're part of the import but only accessed as types, which should work out of the box. +// export class App {} +//export class MarkdownView {} +// export class Editor {} +``` + +> [!attention] Enhancing mocks when necessary +> For every new element you access from the Obsidian API, except for types, you'll need to enhance `__mocks__/obsidian.ts` with appropiate mocks to test your code. + +This will provide mocks for all functions that are called in `onload()`. Run `npm run test` again - your test will unfortunately still fail. +##### Use jsdom to enable usage of browser specific API + +With the obsidian mock in place, your test will still fail, because the example plugin accesses `document` and `window` in `onload()`. This fails since jest is by default running in a `node` test environment that does not provide such objects. The most straight forward way to fix this is to switch to the jsdom test environment that emulates the capabilities of a browser (or electron app). + +1. Open a terminal and run `npm i jest-environment-jsdom` +2. In `jest.config.js` adjust `testEnvironment` to use `jsdom`: `testEnvironment: "jsdom",` +3. Run the test via `npm run test`. Both test cases should be green. + +#### Extract Business Logic while keeping Obsidian API usage + +You can avoid or limit mocking obsidian by loosening the dependency of the code to test from obsidian API usages. + +As detailed in the first approach, you'll be only interested in testing your own code and need to assume that third party code, including the Obsidian API, is doing its job. This opens up the opportunity to extract any business logic you're doing in a separate file and put this file under test instead of `main.ts`. + +Lets assume you want to test following code of the main.ts (line 19 to 38): + +```ts +// This creates an icon in the left ribbon. +const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { + // Called when the user clicks the icon. + new Notice('This is a notice!'); +}); +``` + +To make this piece of code testable without depending on the obsidian API, we need to cut out custom logic from the rest. In this example, it's the callback function of `addRibbonIcon`. + +> [!hint] Structuring with folders +> For reason of simplicity, this guide adds files to the root folder. Generally, it is a good idea to structure your code in folders, i.e. productive code in `src` and tests in `tests` to keep your code base better maintainable. + +1. Create a new file `myplugin.ts` in your projects root folder and add a default exported class to it: + +```ts +// myplugin.ts +export default class MyPluginLogic { + +} +``` + +2. Extract the RibbonIcon callback to a function (see [[How to test plugin code that uses Obsidian APIs#Move logic out to separate files]] for more infos how to extract code) + +```ts +// myplugin.ts +export default class MyPluginLogic { + static ribbonIconCallback() { + // Called when the user clicks the icon. + new Notice('This is a notice!'); + } +} +``` + +This still carries a call to the Obsidian API (to `Notice`) that needs to be extracted. To not depend on the Obsidian API in our custom file, we'll instead pass the dependency in from our calling context (which will be `main.ts`). + +3. Pass in Obsidian API depending functionality as function parameter. Please note that `any` is not a good choice for a type - we'll take a look at this problem in [[How to add automated tests to your plugin#Using Obsidian API for typing in extracted business logic|Using Obsidian API for typing in extracted business logic]]. + +```ts +// myplugin.ts +export default class MyPluginLogic { + static ribbonIconCallback(notice: any) { + // Called when the user clicks the icon. + new notice('This is a notice!'); + } +} +``` + +4. Adjust the callback in `main.ts` to use our extracted function + +```ts +// main.ts +import MyPluginLogic from "./myplugin"; + +// [other code ...] +const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { + MyPluginLogic.ribbonIconCallback(Notice) +}); +``` + +Restart Obsidian to check manually that the ribbon icon is still showing you the notice when clicked. + +> [!attention] Correct import path +> It might happen that Visual Studio Code auto-imports `MyPluginLogic` like this: +> ```ts +> import MyPluginLogic from "myplugin"; +> ``` +> Mind the missing `./` in front of the file name. If your code is not working, try adjusting the import to `import MyPluginLogic from "./myplugin";` + +Having the business logic extracted, it's now possible to put `myplugin.ts` under test instead of `main.ts` and avoid mocking obsidian API. + +5. Remove or comment out `__mocks__/obsidian.ts` to make sure the following works without any mocks. +6. Replace the content of `example.spec.ts` with: + +```ts +// example.spec.ts +import MyPluginLogic from "./myplugin"; + +describe("MyPlugin Tests", () => { + it('should create a notice with This is a notice! as text', () => { + const mockNotice = jest.fn(); + MyPluginLogic.ribbonIconCallback(mockNotice) + + expect(mockNotice).toHaveBeenCalledWith("This is a notice!") + }) +}); +``` + +6. Run the test via `npm run test`. It should be green. + +Mind that you will not be able to test if you really registered a corresponding ribbonIcon with this approach. Generally, separating business logic from the logic thats necessary to integrate with Obsidian can help with readability and maintainability and might be valuable not only for testing. + +##### Using Obsidian API for typing in extracted business logic + +As previously mentioned, using types of the Obsidian API is not a problem per se. To leverage the power of typescript and use the correct types, we can enhance `myplugin.ts` to correctly type the dependency the callback expects, even if this means having an Obsidian API import inside the code. + +Since we're using a constructor here, you cannot just write `notice: Notice`, which would expect a already constructed Notice object; instead, use an [abstract construct signature](https://www.typescriptlang.org/docs/handbook/2/classes.html#abstract-construct-signatures). + +```ts +// myplugin.ts +import { Notice } from "obsidian"; + +export default class MyPluginLogic { + static ribbonIconCallback(notice: new (msg: string) => Notice) { + // Called when the user clicks the icon. + new notice('This is a notice!'); + } +} +``` + +Run your test via `npm run test`. It should still be green, without any mock in place. + +#### Extract Business Logic and abstract from Obsidian API usage + +The previous example does not appear too useful since we extracted business logic that only called, again, an Obsidian API function. The example plugin is designed to showcase usage of Obsidian API and thus makes heavily use of it, which makes it unsuited for an example for extracting pure business logic. To showcase this, it is necessary to first enhance the example plugin with some more functionality. Replace line 14 to 23 of `main.ts` with following: + +```ts +// main.ts + +// [...imports, const, interfaces...] +export default class MyPlugin extends Plugin { + settings: MyPluginSettings; + data: { + numberOfRolls: number; + username: string; + limit?: number; + } + + async onload() { + await this.loadSettings(); + // mocking some data for showcasing purposes + this.data = { + numberOfRolls: 3, + username: 'Alice', + limit: 6 + } + + // This creates an icon in the left ribbon. + const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { + // Called when the user clicks the icon. + let message; + if (this.data.limit && this.data.limit <= this.data.numberOfRolls) { + message = "Oh no, you've surpassed your daily limit!" + } else { + const diceRoll = Math.floor(Math.random() * 7); + this.data.numberOfRolls++; + + message = `Hey, ${this.data.username}! You've rolled a ${diceRoll} - this was your ${this.data.numberOfRolls} roll today.` + + if (this.data.limit && this.data.numberOfRolls >= (this.data.limit - 2)) { + message += ` Watch out! You only have ${this.data.limit - this.data.numberOfRolls} rolls left today!` + } + } + + new Notice(message); + }); + // [... rest of the code] +``` + +This will return you a virtual dice roll number as long as you stay under the configured limit. None of this code depends directly on obsidian and makes it a good candidate to extract it to a separate function in a separate file. Replace the content of `myplugin.ts` with following code: + +```ts +// myplugin.ts +export default class MyPluginLogic { +static generateDiceRollMessage(data: any): string { + let message; + if (data.limit && data.limit <= data.numberOfRolls) { + message = "Oh no, you've surpassed your daily limit!" + } else { + const diceRoll = Math.floor(Math.random() * 7); + data.numberOfRolls++; + + message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.` + + if (data.limit && data.numberOfRolls >= (data.limit -2)) { + message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!` + } + } + + return message; +} +``` + +> [!info] Any typing is bad typing +> Instead of `any`, you'd provide an interface - in a third file - that describes your data structure and use it here as a type, like the already existing `MyPluginSettings`. Due to simplicity, we'll leave that out here. + +Use the extracted function in `main.ts`: +```js +// main.ts +const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { + // Called when the user clicks the icon. + const message = MyPluginLogic.generateDiceRollMessage(this.data) + new Notice(message); +}); +} +``` + +It's possible to test this code by i.e. making sure that the limit is respected without providing any mock for obsidian. + +```ts +// example.spec.ts +import MyPluginLogic from "./myplugin"; + +describe("MyPlugin Tests", () => { + it('should display a static message if the configured limit is exceeded', () => { + const mockData = { + username: "Bob", + numberOfRolls: 10, + limit: 8 + } + + const message = MyPluginLogic.generateDiceRollMessage(mockData); + + expect(message).toEqual("Oh no, you've surpassed your daily limit!") + }) +}); +``` +## Closing words + +While setting up tests might appear tedious at times and is a skill to learn, with a growing code base and growing complexity automated tests can be an enormous help and sometimes the only way to make sure that nothing is seriously broken. It'll give you the confidence to do refactors to your code without the need of extensive manual tests and will also encourage a cleaner structure of productive code for the sake of test-ability. -For now, see the talk [[Plugin Testing for Developers]]. %% Hub footer: Please don't edit anything below this line %% From d02f7746acd6b9d13662707ec317b74b04b1bdd7 Mon Sep 17 00:00:00 2001 From: s-blu Date: Mon, 13 Jan 2025 12:22:47 +0100 Subject: [PATCH 2/8] Detail limitations of testing without any obsidian dependency --- .../Guides/How to add automated tests to your plugin.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index da1af8f58..77f6eaf95 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -393,6 +393,8 @@ describe("MyPlugin Tests", () => { }) }); ``` + +This does not test if the notice is really shown, but enables you to test your custom logic. You could opt in to manage showing the notice in your extracted function, as well, like shown in the previous example. How you split your code and which parts you want to put under test can be a difficult question to answer and depends on the individual code you're writing, its complexity and importance of the functionality. As mentioned before, combining these approaches to test all relevant parts of your plugin is a valid way to go. ## Closing words While setting up tests might appear tedious at times and is a skill to learn, with a growing code base and growing complexity automated tests can be an enormous help and sometimes the only way to make sure that nothing is seriously broken. It'll give you the confidence to do refactors to your code without the need of extensive manual tests and will also encourage a cleaner structure of productive code for the sake of test-ability. From 4b999ca99c16712c1ddcfb8fc563ffac51cb1304 Mon Sep 17 00:00:00 2001 From: s-blu Date: Mon, 13 Jan 2025 14:31:29 +0100 Subject: [PATCH 3/8] reduce intendation of code example; add some more context to last extraction explanation --- ...w to add automated tests to your plugin.md | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index 77f6eaf95..adcb623f6 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -152,7 +152,7 @@ export class Setting {} // mocking these classes is not necessary. They're part of the import but only accessed as types, which should work out of the box. // export class App {} -//export class MarkdownView {} +// export class MarkdownView {} // export class Editor {} ``` @@ -297,42 +297,42 @@ The previous example does not appear too useful since we extracted business logi // [...imports, const, interfaces...] export default class MyPlugin extends Plugin { - settings: MyPluginSettings; - data: { - numberOfRolls: number; - username: string; - limit?: number; - } - - async onload() { - await this.loadSettings(); - // mocking some data for showcasing purposes - this.data = { - numberOfRolls: 3, - username: 'Alice', - limit: 6 - } - - // This creates an icon in the left ribbon. - const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - // Called when the user clicks the icon. - let message; - if (this.data.limit && this.data.limit <= this.data.numberOfRolls) { - message = "Oh no, you've surpassed your daily limit!" - } else { - const diceRoll = Math.floor(Math.random() * 7); - this.data.numberOfRolls++; - - message = `Hey, ${this.data.username}! You've rolled a ${diceRoll} - this was your ${this.data.numberOfRolls} roll today.` - - if (this.data.limit && this.data.numberOfRolls >= (this.data.limit - 2)) { - message += ` Watch out! You only have ${this.data.limit - this.data.numberOfRolls} rolls left today!` - } - } - - new Notice(message); - }); - // [... rest of the code] + settings: MyPluginSettings; + data: { + numberOfRolls: number; + username: string; + limit?: number; + } + + async onload() { + await this.loadSettings(); + // mocking some data for showcasing purposes + this.data = { + numberOfRolls: 3, + username: 'Alice', + limit: 6 + } + + // This creates an icon in the left ribbon. + const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { + // Called when the user clicks the icon. + let message; + if (this.data.limit && this.data.limit <= this.data.numberOfRolls) { + message = "Oh no, you've surpassed your daily limit!" + } else { + const diceRoll = Math.floor(Math.random() * 7); + this.data.numberOfRolls++; + + message = `Hey, ${this.data.username}! You've rolled a ${diceRoll} - this was your ${this.data.numberOfRolls} roll today.` + + if (this.data.limit && this.data.numberOfRolls >= (this.data.limit - 2)) { + message += ` Watch out! You only have ${this.data.limit - this.data.numberOfRolls} rolls left today!` + } + } + + new Notice(message); + }); + // [... rest of the code] ``` This will return you a virtual dice roll number as long as you stay under the configured limit. None of this code depends directly on obsidian and makes it a good candidate to extract it to a separate function in a separate file. Replace the content of `myplugin.ts` with following code: @@ -362,7 +362,8 @@ static generateDiceRollMessage(data: any): string { > [!info] Any typing is bad typing > Instead of `any`, you'd provide an interface - in a third file - that describes your data structure and use it here as a type, like the already existing `MyPluginSettings`. Due to simplicity, we'll leave that out here. -Use the extracted function in `main.ts`: +Since `data` is fetched (normally) by a Obsidian API call, we'll fetch it beforehand and pass it to the function. This will require a small refactor, since you'll need to remove `this.` before `data`. Showing the Notice will stay outside of the extracted function too, to not have any dependency on Obsidian API. +All that's left is to use the extracted function in `main.ts`: ```js // main.ts const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { @@ -373,7 +374,7 @@ const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEven } ``` -It's possible to test this code by i.e. making sure that the limit is respected without providing any mock for obsidian. +It's possible to test the extracted code by i.e. making sure that the limit is respected without providing any mock for obsidian. ```ts // example.spec.ts @@ -394,7 +395,7 @@ describe("MyPlugin Tests", () => { }); ``` -This does not test if the notice is really shown, but enables you to test your custom logic. You could opt in to manage showing the notice in your extracted function, as well, like shown in the previous example. How you split your code and which parts you want to put under test can be a difficult question to answer and depends on the individual code you're writing, its complexity and importance of the functionality. As mentioned before, combining these approaches to test all relevant parts of your plugin is a valid way to go. +This does not test if the notice is really shown, but enables you to test your custom logic that builds the message without the need of potentially complex mocks. You could opt in to manage showing the notice in your extracted function, as well, like shown in the previous example. How you split your code and which parts you want to put under test can be a difficult question to answer and depends on the individual code you're writing, its complexity and importance. As mentioned before, combining the presented approaches to test all relevant parts of your plugin is a valid way to go. ## Closing words While setting up tests might appear tedious at times and is a skill to learn, with a growing code base and growing complexity automated tests can be an enormous help and sometimes the only way to make sure that nothing is seriously broken. It'll give you the confidence to do refactors to your code without the need of extensive manual tests and will also encourage a cleaner structure of productive code for the sake of test-ability. From 03a0bd04d903efa1c3cd16970aa0a99c789ea507 Mon Sep 17 00:00:00 2001 From: s-blu Date: Mon, 13 Jan 2025 21:09:25 +0100 Subject: [PATCH 4/8] capitalize jest and obsidian, add semicolons to code examples link obsidian example plugin github repository --- ...w to add automated tests to your plugin.md | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index adcb623f6..44e1d140b 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -9,24 +9,25 @@ publish: true %% Add a description below this line. It doesn't need to be long: one or two sentences should be a good start. %% -This page will guide you through integrating [jest](https://jestjs.io/docs/getting-started) as a testing framework to the obsidian example plugin to enable automated testing for your plugin. Furthermore, the guide will cover specialties when running tests using the obsidian API. +This page will guide you through integrating [Jest](https://jestjs.io/docs/getting-started) as a testing framework to the Obsidian example plugin to enable automated testing for your plugin. Furthermore, the guide will cover specialties when running tests using the Obsidian API. -This guide will use the obsidian example plugin as a showcase. It also assumes knowledge about unit testing to a certain degree. If you wish to go through the guide step-by-step, please download and enable the example plugin like described in [Build a plugin](https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin). +This guide will use the [Obsidian example plugin](https://github.com/obsidianmd/obsidian-sample-plugin) as a showcase. It also assumes knowledge about unit testing to a certain degree. If you wish to go through the guide step-by-step, please download and enable the example plugin like described in [Build a plugin](https://docs.Obsidian.md/Plugins/Getting+started/Build+a+plugin). -This guide focused on how to integrate jest to your plugin and what steps to take to deal with the Obsidian API when running plugin code in a test environment. It does not provide guidance how to write test cases or what good tests are. Please refer to [[Plugin Testing for Developers]] for a deeper look at these topics as well as [[How to find examples of Jest-based plugin tests]] for examples. -## Integrate jest into obsidian-example-plugin +This guide focused on how to integrate Jest to your plugin and what steps to take to deal with the Obsidian API when running plugin code in a test environment. It does not provide guidance how to write test cases or what good tests are. Please refer to [[Plugin Testing for Developers]] for a deeper look at these topics as well as [[How to find examples of Jest-based plugin tests]] for examples. -Integrating jest to a project and make it compatible with typescript code requires some additional dependencies. +## Integrate Jest into Obsidian-example-plugin + +Integrating Jest to a project and make it compatible with typescript code requires some additional dependencies. 1. Open up a terminal and navigate to your plugins root folder -2. Install jest via `npm install --save-dev jest` +2. Install Jest via `npm install --save-dev jest` 3. Install types for correct typing in typescript files: `npm install --save-dev @types/jest` 4. Install ts-jest to transpile typescript files: `npm install --save-dev ts-jest` -5. Create a jest configuration file by running `npx ts-jest config:init` +5. Create a Jest configuration file by running `npx ts-jest config:init` 6. Open `tsconfig.json` and add `"esModuleInterop": true` as an additional property to `compilerOptions` 7. Open `package.json` and add `"test": "jest"` to the `scripts` section -jest is now integrated into the project and usable for testing. +Jest is now integrated into the project and usable for testing. ## Executing a first automated test @@ -38,7 +39,7 @@ jest is now integrated into the project and usable for testing. > [!info] Test file names > It is not required to call tests with a `.spec.ts` ending, `.ts` is enough. However, it help you to see at one glance which file contain productive code and which are test files, which will help with development and maintainability. -2. In `example.spec.ts`, add a `describe` block. Read more about describe blocks in [jests documentation](https://jestjs.io/docs/api#describename-fn). Basically, a `describe` bundles up multiple test cases for better readability. +2. In `example.spec.ts`, add a `describe` block. Read more about describe blocks in [Jests documentation](https://jestjs.io/docs/api#describename-fn). Basically, a `describe` bundles up multiple test cases for better readability. ```ts // example.spec.ts @@ -47,7 +48,7 @@ describe('MyPlugin Tests', () => { }) ``` -3. To the describe, add a test case. Please note that `it` and `test` are aliases, so you can also call `test`, depending on your preference. Read more in [jests documentation](https://jestjs.io/docs/api#testname-fn-timeout) +3. To the describe, add a test case. Please note that `it` and `test` are aliases, so you can also call `test`, depending on your preference. Read more in [Jests documentation](https://jestjs.io/docs/api#testname-fn-timeout) ```ts // example.spec.ts @@ -55,19 +56,19 @@ describe('MyPlugin Tests', () => {   it('should be able to execute test', () => {     expect("hello").toBeTruthy(); -  }) -}) +  }); +}); // this is necessary to conform the isolatedModules compiler option and can be removed as soon as an import is added -export {} +export {}; ``` -4. To execute the test, open a terminal and execute `npm run test`. This triggers the script you've added to the package.json and should execute jest, showing you a successful test run. +4. To execute the test, open a terminal and execute `npm run test`. This triggers the script you've added to the package.json and should execute Jest, showing you a successful test run. ### Testing plugin code -The previous test case made sure that jest is correctly integrated to the project. In general, testing a static value does not hold much value. The goal of automated testing is to runs code and ensure that it meets certain expectations. In our case, we want to test plugin related code. +The previous test case made sure that Jest is correctly integrated to the project. In general, testing a static value does not hold much value. The goal of automated testing is to runs code and ensure that it meets certain expectations. In our case, we want to test plugin related code. -Unfortunately, Obsidian API is not straight forward to test. Obsidian functionality is only available if the code runs in context of an Obsidian app and will not be available when running code in our testing environment. The obsidian node module contains types only. This means jest will fail to find and execute i.e. classes and functions. It will work fine for types. +Unfortunately, Obsidian API is not straight forward to test. Obsidian functionality is only available if the code runs in context of an Obsidian app and will not be available when running code in our testing environment. The Obsidian node module contains types only. This means Jest will fail to find and execute i.e. classes and functions. It will work fine for types. First of all, exchange the static test case from before with one using the plugin code. Replace your test file content with: @@ -88,7 +89,7 @@ Run `npm run test`. It'll fail with an `Cannot find module 'obsidian' from 'main There are multiple ways working around this error and this guide will detail two of them. They're not exclusive. Depending on the scope of your plugin it might be worthwhile to combine both approaches. #### Mocking Obsidian API -jest carries functionality to mock a variety of things, including dependencies. In case of the Obsidian API, a [manual ES6 class mock](https://jestjs.io/docs/es6-class-mocks#manual-mock) is required. +Jest carries functionality to mock a variety of things, including dependencies. In case of the Obsidian API, a [manual ES6 class mock](https://jestjs.io/docs/es6-class-mocks#manual-mock) is required. Mocking the Obsidian API is uncritical in sense of testing your code. When testing, you will need to assume that any third party dependency, including the Obsidian API, is working like expected and concentrate testing only code under your own responsibility. Thus, mocking the Obsidian API does not reduce the value of automated tests. @@ -108,7 +109,7 @@ export class Setting {} ``` 3. Run `npm run test` again and your test should succeed. -This mock is only enough to enable jest to read `main.ts` at all. It is insufficient for other scenarios. +This mock is only enough to enable Jest to read `main.ts` at all. It is insufficient for other scenarios. For example, add a second test case below the existing one: ```ts @@ -119,8 +120,8 @@ For example, add a second test case below the existing one: expect(plugin.settings).toEqual({ mySetting: 'default' - }) - }) + }); + }); ``` Run it with `npm run test`. It'll fail due to a `TypeError: this.loadData is not a function` error. @@ -135,12 +136,12 @@ export class Plugin { addRibbonIcon() { return { addClass: () => {} - } + }; } addStatusBarItem() { return { setText: () => {} - } + }; } addCommand() {} addSettingTab() {} @@ -162,7 +163,7 @@ export class Setting {} This will provide mocks for all functions that are called in `onload()`. Run `npm run test` again - your test will unfortunately still fail. ##### Use jsdom to enable usage of browser specific API -With the obsidian mock in place, your test will still fail, because the example plugin accesses `document` and `window` in `onload()`. This fails since jest is by default running in a `node` test environment that does not provide such objects. The most straight forward way to fix this is to switch to the jsdom test environment that emulates the capabilities of a browser (or electron app). +With the Obsidian mock in place, your test will still fail, because the example plugin accesses `document` and `window` in `onload()`. This fails since Jest is by default running in a `node` test environment that does not provide such objects. The most straight forward way to fix this is to switch to the jsdom test environment that emulates the capabilities of a browser (or electron app). 1. Open a terminal and run `npm i jest-environment-jsdom` 2. In `jest.config.js` adjust `testEnvironment` to use `jsdom`: `testEnvironment: "jsdom",` @@ -170,7 +171,7 @@ With the obsidian mock in place, your test will still fail, because the example #### Extract Business Logic while keeping Obsidian API usage -You can avoid or limit mocking obsidian by loosening the dependency of the code to test from obsidian API usages. +You can avoid or limit mocking Obsidian by loosening the dependency of the code to test from Obsidian API usages. As detailed in the first approach, you'll be only interested in testing your own code and need to assume that third party code, including the Obsidian API, is doing its job. This opens up the opportunity to extract any business logic you're doing in a separate file and put this file under test instead of `main.ts`. @@ -184,7 +185,7 @@ const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEven }); ``` -To make this piece of code testable without depending on the obsidian API, we need to cut out custom logic from the rest. In this example, it's the callback function of `addRibbonIcon`. +To make this piece of code testable without depending on the Obsidian API, we need to cut out custom logic from the rest. In this example, it's the callback function of `addRibbonIcon`. > [!hint] Structuring with folders > For reason of simplicity, this guide adds files to the root folder. Generally, it is a good idea to structure your code in folders, i.e. productive code in `src` and tests in `tests` to keep your code base better maintainable. @@ -232,7 +233,7 @@ import MyPluginLogic from "./myplugin"; // [other code ...] const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - MyPluginLogic.ribbonIconCallback(Notice) + MyPluginLogic.ribbonIconCallback(Notice); }); ``` @@ -245,7 +246,7 @@ Restart Obsidian to check manually that the ribbon icon is still showing you the > ``` > Mind the missing `./` in front of the file name. If your code is not working, try adjusting the import to `import MyPluginLogic from "./myplugin";` -Having the business logic extracted, it's now possible to put `myplugin.ts` under test instead of `main.ts` and avoid mocking obsidian API. +Having the business logic extracted, it's now possible to put `myplugin.ts` under test instead of `main.ts` and avoid mocking Obsidian API. 5. Remove or comment out `__mocks__/obsidian.ts` to make sure the following works without any mocks. 6. Replace the content of `example.spec.ts` with: @@ -257,10 +258,10 @@ import MyPluginLogic from "./myplugin"; describe("MyPlugin Tests", () => { it('should create a notice with This is a notice! as text', () => { const mockNotice = jest.fn(); - MyPluginLogic.ribbonIconCallback(mockNotice) + MyPluginLogic.ribbonIconCallback(mockNotice); - expect(mockNotice).toHaveBeenCalledWith("This is a notice!") - }) + expect(mockNotice).toHaveBeenCalledWith("This is a notice!"); + }); }); ``` @@ -276,7 +277,7 @@ Since we're using a constructor here, you cannot just write `notice: Notice`, wh ```ts // myplugin.ts -import { Notice } from "obsidian"; +import { Notice } from "Obsidian"; export default class MyPluginLogic { static ribbonIconCallback(notice: new (msg: string) => Notice) { @@ -335,7 +336,7 @@ export default class MyPlugin extends Plugin { // [... rest of the code] ``` -This will return you a virtual dice roll number as long as you stay under the configured limit. None of this code depends directly on obsidian and makes it a good candidate to extract it to a separate function in a separate file. Replace the content of `myplugin.ts` with following code: +This will return you a virtual dice roll number as long as you stay under the configured limit. None of this code depends directly on Obsidian and makes it a good candidate to extract it to a separate function in a separate file. Replace the content of `myplugin.ts` with following code: ```ts // myplugin.ts @@ -343,15 +344,15 @@ export default class MyPluginLogic { static generateDiceRollMessage(data: any): string { let message; if (data.limit && data.limit <= data.numberOfRolls) { - message = "Oh no, you've surpassed your daily limit!" + message = "Oh no, you've surpassed your daily limit!"; } else { const diceRoll = Math.floor(Math.random() * 7); data.numberOfRolls++; - message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.` + message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; if (data.limit && data.numberOfRolls >= (data.limit -2)) { - message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!` + message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; } } @@ -368,13 +369,13 @@ All that's left is to use the extracted function in `main.ts`: // main.ts const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { // Called when the user clicks the icon. - const message = MyPluginLogic.generateDiceRollMessage(this.data) + const message = MyPluginLogic.generateDiceRollMessage(this.data); new Notice(message); }); } ``` -It's possible to test the extracted code by i.e. making sure that the limit is respected without providing any mock for obsidian. +It's possible to test the extracted code by i.e. making sure that the limit is respected without providing any mock for Obsidian. ```ts // example.spec.ts @@ -386,11 +387,11 @@ describe("MyPlugin Tests", () => { username: "Bob", numberOfRolls: 10, limit: 8 - } + }; const message = MyPluginLogic.generateDiceRollMessage(mockData); - expect(message).toEqual("Oh no, you've surpassed your daily limit!") + expect(message).toEqual("Oh no, you've surpassed your daily limit!"); }) }); ``` @@ -405,4 +406,4 @@ While setting up tests might appear tedious at times and is a skill to learn, wi # This note in GitHub -[Edit In GitHub](https://github.dev/obsidian-community/obsidian-hub/blob/main/04%20-%20Guides%2C%20Workflows%2C%20%26%20Courses/Guides/How%20to%20add%20automated%20tests%20to%20your%20plugin.md "git-hub-edit-note") | [Copy this note](https://raw.githubusercontent.com/obsidian-community/obsidian-hub/main/04%20-%20Guides%2C%20Workflows%2C%20%26%20Courses/Guides/How%20to%20add%20automated%20tests%20to%20your%20plugin.md "git-hub-copy-note") | [Download this vault](https://github.com/obsidian-community/obsidian-hub/archive/refs/heads/main.zip "git-hub-download-vault") +[Edit In GitHub](https://github.dev/Obsidian-community/Obsidian-hub/blob/main/04%20-%20Guides%2C%20Workflows%2C%20%26%20Courses/Guides/How%20to%20add%20automated%20tests%20to%20your%20plugin.md "git-hub-edit-note") | [Copy this note](https://raw.githubusercontent.com/Obsidian-community/Obsidian-hub/main/04%20-%20Guides%2C%20Workflows%2C%20%26%20Courses/Guides/How%20to%20add%20automated%20tests%20to%20your%20plugin.md "git-hub-copy-note") | [Download this vault](https://github.com/Obsidian-community/Obsidian-hub/archive/refs/heads/main.zip "git-hub-download-vault") From 043f6e56fd38a58da67e39779ce483f6a168529f Mon Sep 17 00:00:00 2001 From: s-blu Date: Mon, 13 Jan 2025 21:20:39 +0100 Subject: [PATCH 5/8] Add empty lines before headings, improve wordings as suggested in PR --- ...w to add automated tests to your plugin.md | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index 44e1d140b..e75aaa421 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -37,7 +37,7 @@ Jest is now integrated into the project and usable for testing. 1. Create a new file named `example.spec.ts` to your projects root folder > [!info] Test file names -> It is not required to call tests with a `.spec.ts` ending, `.ts` is enough. However, it help you to see at one glance which file contain productive code and which are test files, which will help with development and maintainability. +> It is not required to call tests with a `.spec.ts` ending, `.ts` is enough. However, it help you to see at one glance which file contain productive code and which are test files, which will help with development and maintainability. It is also common to end test file names with `.test.ts` instead of `.spec.ts`. 2. In `example.spec.ts`, add a `describe` block. Read more about describe blocks in [Jests documentation](https://jestjs.io/docs/api#describename-fn). Basically, a `describe` bundles up multiple test cases for better readability. @@ -64,13 +64,14 @@ export {}; ``` 4. To execute the test, open a terminal and execute `npm run test`. This triggers the script you've added to the package.json and should execute Jest, showing you a successful test run. + ### Testing plugin code The previous test case made sure that Jest is correctly integrated to the project. In general, testing a static value does not hold much value. The goal of automated testing is to runs code and ensure that it meets certain expectations. In our case, we want to test plugin related code. Unfortunately, Obsidian API is not straight forward to test. Obsidian functionality is only available if the code runs in context of an Obsidian app and will not be available when running code in our testing environment. The Obsidian node module contains types only. This means Jest will fail to find and execute i.e. classes and functions. It will work fine for types. -First of all, exchange the static test case from before with one using the plugin code. Replace your test file content with: +First of all, exchange the static test case from before with one using the plugin class. Replace your test file content with: ```ts // example.spec.ts @@ -87,6 +88,7 @@ describe("MyPlugin Tests", () => { Run `npm run test`. It'll fail with an `Cannot find module 'obsidian' from 'main.ts'` error due to the reasons explained above. There are multiple ways working around this error and this guide will detail two of them. They're not exclusive. Depending on the scope of your plugin it might be worthwhile to combine both approaches. + #### Mocking Obsidian API Jest carries functionality to mock a variety of things, including dependencies. In case of the Obsidian API, a [manual ES6 class mock](https://jestjs.io/docs/es6-class-mocks#manual-mock) is required. @@ -161,6 +163,7 @@ export class Setting {} > For every new element you access from the Obsidian API, except for types, you'll need to enhance `__mocks__/obsidian.ts` with appropiate mocks to test your code. This will provide mocks for all functions that are called in `onload()`. Run `npm run test` again - your test will unfortunately still fail. + ##### Use jsdom to enable usage of browser specific API With the Obsidian mock in place, your test will still fail, because the example plugin accesses `document` and `window` in `onload()`. This fails since Jest is by default running in a `node` test environment that does not provide such objects. The most straight forward way to fix this is to switch to the jsdom test environment that emulates the capabilities of a browser (or electron app). @@ -199,7 +202,7 @@ export default class MyPluginLogic { } ``` -2. Extract the RibbonIcon callback to a function (see [[How to test plugin code that uses Obsidian APIs#Move logic out to separate files]] for more infos how to extract code) +2. Extract the RibbonIcon callback to a function (see [[How to test plugin code that uses Obsidian APIs#Move logic out to separate files]] for more infos how to extract code) ```ts // myplugin.ts @@ -267,7 +270,7 @@ describe("MyPlugin Tests", () => { 6. Run the test via `npm run test`. It should be green. -Mind that you will not be able to test if you really registered a corresponding ribbonIcon with this approach. Generally, separating business logic from the logic thats necessary to integrate with Obsidian can help with readability and maintainability and might be valuable not only for testing. +Mind that you will not be able to test if you really registered a corresponding ribbon icon with this approach. Generally, separating business logic from the logic that's necessary to integrate with Obsidian can help with readability and maintainability and might be valuable not only for testing. ##### Using Obsidian API for typing in extracted business logic @@ -341,22 +344,23 @@ This will return you a virtual dice roll number as long as you stay under the co ```ts // myplugin.ts export default class MyPluginLogic { -static generateDiceRollMessage(data: any): string { - let message; - if (data.limit && data.limit <= data.numberOfRolls) { - message = "Oh no, you've surpassed your daily limit!"; - } else { - const diceRoll = Math.floor(Math.random() * 7); - data.numberOfRolls++; - - message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; - - if (data.limit && data.numberOfRolls >= (data.limit -2)) { - message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; - } - } + static generateDiceRollMessage(data: any): string { + let message; + if (data.limit && data.limit <= data.numberOfRolls) { + message = "Oh no, you've surpassed your daily limit!"; + } else { + const diceRoll = Math.floor(Math.random() * 7); + data.numberOfRolls++; + + message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; + + if (data.limit && data.numberOfRolls >= (data.limit -2)) { + message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; + } + } - return message; + return message; + } } ``` @@ -396,7 +400,8 @@ describe("MyPlugin Tests", () => { }); ``` -This does not test if the notice is really shown, but enables you to test your custom logic that builds the message without the need of potentially complex mocks. You could opt in to manage showing the notice in your extracted function, as well, like shown in the previous example. How you split your code and which parts you want to put under test can be a difficult question to answer and depends on the individual code you're writing, its complexity and importance. As mentioned before, combining the presented approaches to test all relevant parts of your plugin is a valid way to go. +This does not test if the notice is really shown, but enables you to test your custom logic that builds the message without the need of potentially complex mocks. You could opt in to manage showing the notice in your extracted function, as well, like shown in the previous example. How you split your code and which parts you want to put under test can be a difficult question to answer and depends on the individual code you're writing, its complexity, and its importance. As mentioned before, combining the presented approaches to test all relevant parts of your plugin is a valid way to go. + ## Closing words While setting up tests might appear tedious at times and is a skill to learn, with a growing code base and growing complexity automated tests can be an enormous help and sometimes the only way to make sure that nothing is seriously broken. It'll give you the confidence to do refactors to your code without the need of extensive manual tests and will also encourage a cleaner structure of productive code for the sake of test-ability. From da7ca22d488d75fff4136cf46a2aa205ed83328a Mon Sep 17 00:00:00 2001 From: s-blu Date: Mon, 13 Jan 2025 22:04:31 +0100 Subject: [PATCH 6/8] enhance wording, add hint for indirect imports --- ...w to add automated tests to your plugin.md | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index e75aaa421..da1798761 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -9,7 +9,7 @@ publish: true %% Add a description below this line. It doesn't need to be long: one or two sentences should be a good start. %% -This page will guide you through integrating [Jest](https://jestjs.io/docs/getting-started) as a testing framework to the Obsidian example plugin to enable automated testing for your plugin. Furthermore, the guide will cover specialties when running tests using the Obsidian API. +This page will guide you through integrating [Jest](https://jestjs.io/docs/getting-started) as a testing framework to the [Obsidian example plugin](https://github.com/obsidianmd/obsidian-sample-plugin) to enable automated testing for your plugin. Furthermore, the guide will cover specialties when running tests using the Obsidian API. This guide will use the [Obsidian example plugin](https://github.com/obsidianmd/obsidian-sample-plugin) as a showcase. It also assumes knowledge about unit testing to a certain degree. If you wish to go through the guide step-by-step, please download and enable the example plugin like described in [Build a plugin](https://docs.Obsidian.md/Plugins/Getting+started/Build+a+plugin). @@ -20,7 +20,7 @@ This guide focused on how to integrate Jest to your plugin and what steps to tak Integrating Jest to a project and make it compatible with typescript code requires some additional dependencies. 1. Open up a terminal and navigate to your plugins root folder -2. Install Jest via `npm install --save-dev jest` +2. Install Jest: `npm install --save-dev jest` 3. Install types for correct typing in typescript files: `npm install --save-dev @types/jest` 4. Install ts-jest to transpile typescript files: `npm install --save-dev ts-jest` 5. Create a Jest configuration file by running `npx ts-jest config:init` @@ -37,7 +37,7 @@ Jest is now integrated into the project and usable for testing. 1. Create a new file named `example.spec.ts` to your projects root folder > [!info] Test file names -> It is not required to call tests with a `.spec.ts` ending, `.ts` is enough. However, it help you to see at one glance which file contain productive code and which are test files, which will help with development and maintainability. It is also common to end test file names with `.test.ts` instead of `.spec.ts`. +> It is not required to call tests with a `.spec.ts` ending, `.ts` is enough. However, it allows you to see at one glance which file contain productive code and which are test files, which will help with development and maintainability. It is also common to end test file names with `.test.ts` instead of `.spec.ts`. 2. In `example.spec.ts`, add a `describe` block. Read more about describe blocks in [Jests documentation](https://jestjs.io/docs/api#describename-fn). Basically, a `describe` bundles up multiple test cases for better readability. @@ -63,13 +63,13 @@ describe('MyPlugin Tests', () => { export {}; ``` -4. To execute the test, open a terminal and execute `npm run test`. This triggers the script you've added to the package.json and should execute Jest, showing you a successful test run. +4. To execute the test, open a terminal and execute `npm run test`. This triggers the script you've added to package.json and should execute Jest, showing you a successful test run. ### Testing plugin code -The previous test case made sure that Jest is correctly integrated to the project. In general, testing a static value does not hold much value. The goal of automated testing is to runs code and ensure that it meets certain expectations. In our case, we want to test plugin related code. +The previous test case made sure that Jest is correctly integrated to the project. In general, testing a string literal does not hold much value. The goal of automated testing is to run code and ensure that it meets certain expectations. In our case, we want to test plugin related code. -Unfortunately, Obsidian API is not straight forward to test. Obsidian functionality is only available if the code runs in context of an Obsidian app and will not be available when running code in our testing environment. The Obsidian node module contains types only. This means Jest will fail to find and execute i.e. classes and functions. It will work fine for types. +Unfortunately, Obsidian API is not straight forward to test. Obsidian functionality is only available if the code runs in context of an Obsidian app and will not be available when running code in our testing environment. The `obsidian` node module contains types only. This means Jest will fail to find and execute i.e. classes and functions. It will work fine for types. First of all, exchange the static test case from before with one using the plugin class. Replace your test file content with: @@ -87,17 +87,21 @@ describe("MyPlugin Tests", () => { Run `npm run test`. It'll fail with an `Cannot find module 'obsidian' from 'main.ts'` error due to the reasons explained above. +> [!info] Obsidian API usage in other files +> Please note that the file under test does not need to use the Obsidian API directly to encounter this error. You'll also run into it if an import in your file under tests refers the Obsidian API or an import of an imported file, etc. + There are multiple ways working around this error and this guide will detail two of them. They're not exclusive. Depending on the scope of your plugin it might be worthwhile to combine both approaches. #### Mocking Obsidian API Jest carries functionality to mock a variety of things, including dependencies. In case of the Obsidian API, a [manual ES6 class mock](https://jestjs.io/docs/es6-class-mocks#manual-mock) is required. -Mocking the Obsidian API is uncritical in sense of testing your code. When testing, you will need to assume that any third party dependency, including the Obsidian API, is working like expected and concentrate testing only code under your own responsibility. Thus, mocking the Obsidian API does not reduce the value of automated tests. +Mocking the Obsidian API does not lessen the usefulness of your tests. When testing, you will need to assume that any third party dependency, including the Obsidian API, is working like expected and concentrate on testing only code under your own responsibility, since this is the only code you can fix. 1. Create a folder named `__mocks__` at the root of your plugin folder 2. In `__mocks__`, create a file called `obsidian.ts` and provide an empty (mock) class for every import listed at the top of `main.ts`: ```ts +// obsidian.ts export class Modal {} export class Notice {} export class Plugin {} @@ -130,6 +134,7 @@ Run it with `npm run test`. It'll fail due to a `TypeError: this.loadData is not `loadData` is provided by the `Plugin` class `MyPlugin` is extending from, but our mock is an empty class and not providing such a function. To have the `onload()` function running successfully, we need to provide mocks for every accessed function from the Obsidian API. Enhance `__mocks__/obsidian.ts` like follows: ```ts +// obsidian.ts export class Modal {} export class Notice {} export class Plugin { @@ -168,19 +173,20 @@ This will provide mocks for all functions that are called in `onload()`. Run `np With the Obsidian mock in place, your test will still fail, because the example plugin accesses `document` and `window` in `onload()`. This fails since Jest is by default running in a `node` test environment that does not provide such objects. The most straight forward way to fix this is to switch to the jsdom test environment that emulates the capabilities of a browser (or electron app). -1. Open a terminal and run `npm i jest-environment-jsdom` +1. Open a terminal, navigate to your plugins root folder and run `npm i jest-environment-jsdom` 2. In `jest.config.js` adjust `testEnvironment` to use `jsdom`: `testEnvironment: "jsdom",` 3. Run the test via `npm run test`. Both test cases should be green. -#### Extract Business Logic while keeping Obsidian API usage +#### Extract business logic while keeping Obsidian API usage -You can avoid or limit mocking Obsidian by loosening the dependency of the code to test from Obsidian API usages. +You can avoid or limit mocking `obsidian` by loosening the dependency of the code to test from Obsidian API usages. As detailed in the first approach, you'll be only interested in testing your own code and need to assume that third party code, including the Obsidian API, is doing its job. This opens up the opportunity to extract any business logic you're doing in a separate file and put this file under test instead of `main.ts`. -Lets assume you want to test following code of the main.ts (line 19 to 38): +Lets assume you want to test following code of `main.ts` (line 19 to 38): ```ts +// main.ts // This creates an icon in the left ribbon. const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { // Called when the user clicks the icon. @@ -202,7 +208,7 @@ export default class MyPluginLogic { } ``` -2. Extract the RibbonIcon callback to a function (see [[How to test plugin code that uses Obsidian APIs#Move logic out to separate files]] for more infos how to extract code) +2. Extract the callback of `addRibbonIcon` to a function (see [[How to test plugin code that uses Obsidian APIs#Move logic out to separate files]] for more infos how to extract code) ```ts // myplugin.ts @@ -228,7 +234,7 @@ export default class MyPluginLogic { } ``` -4. Adjust the callback in `main.ts` to use our extracted function +4. Adjust the callback in `main.ts` to use the extracted function ```ts // main.ts @@ -249,7 +255,7 @@ Restart Obsidian to check manually that the ribbon icon is still showing you the > ``` > Mind the missing `./` in front of the file name. If your code is not working, try adjusting the import to `import MyPluginLogic from "./myplugin";` -Having the business logic extracted, it's now possible to put `myplugin.ts` under test instead of `main.ts` and avoid mocking Obsidian API. +Having the business logic extracted, it's now possible to test `myplugin.ts` instead of `main.ts` and avoid mocking `obsidian`. 5. Remove or comment out `__mocks__/obsidian.ts` to make sure the following works without any mocks. 6. Replace the content of `example.spec.ts` with: @@ -274,13 +280,13 @@ Mind that you will not be able to test if you really registered a corresponding ##### Using Obsidian API for typing in extracted business logic -As previously mentioned, using types of the Obsidian API is not a problem per se. To leverage the power of typescript and use the correct types, we can enhance `myplugin.ts` to correctly type the dependency the callback expects, even if this means having an Obsidian API import inside the code. +As previously mentioned, using types of the Obsidian API is not a problem per se. To leverage the power of typescript and use the correct types, we can enhance `myplugin.ts` to correctly type the dependency the callback expects, even if this means having an `obsidian` import inside the code. -Since we're using a constructor here, you cannot just write `notice: Notice`, which would expect a already constructed Notice object; instead, use an [abstract construct signature](https://www.typescriptlang.org/docs/handbook/2/classes.html#abstract-construct-signatures). +Since we're using a constructor here, you cannot just type the parameter as `notice: Notice`. This would expect a already constructed Notice object; instead, use an [abstract construct signature](https://www.typescriptlang.org/docs/handbook/2/classes.html#abstract-construct-signatures). ```ts // myplugin.ts -import { Notice } from "Obsidian"; +import { Notice } from "obsidian"; export default class MyPluginLogic { static ribbonIconCallback(notice: new (msg: string) => Notice) { @@ -292,7 +298,7 @@ export default class MyPluginLogic { Run your test via `npm run test`. It should still be green, without any mock in place. -#### Extract Business Logic and abstract from Obsidian API usage +#### Extract business logic and abstract from Obsidian API usage The previous example does not appear too useful since we extracted business logic that only called, again, an Obsidian API function. The example plugin is designed to showcase usage of Obsidian API and thus makes heavily use of it, which makes it unsuited for an example for extracting pure business logic. To showcase this, it is necessary to first enhance the example plugin with some more functionality. Replace line 14 to 23 of `main.ts` with following: @@ -315,22 +321,22 @@ export default class MyPlugin extends Plugin { numberOfRolls: 3, username: 'Alice', limit: 6 - } + }; // This creates an icon in the left ribbon. const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { // Called when the user clicks the icon. let message; if (this.data.limit && this.data.limit <= this.data.numberOfRolls) { - message = "Oh no, you've surpassed your daily limit!" + message = "Oh no, you've surpassed your daily limit!"; } else { const diceRoll = Math.floor(Math.random() * 7); this.data.numberOfRolls++; - message = `Hey, ${this.data.username}! You've rolled a ${diceRoll} - this was your ${this.data.numberOfRolls} roll today.` + message = `Hey, ${this.data.username}! You've rolled a ${diceRoll} - this was your ${this.data.numberOfRolls} roll today.`; if (this.data.limit && this.data.numberOfRolls >= (this.data.limit - 2)) { - message += ` Watch out! You only have ${this.data.limit - this.data.numberOfRolls} rolls left today!` + message += ` Watch out! You only have ${this.data.limit - this.data.numberOfRolls} rolls left today!`; } } @@ -354,7 +360,7 @@ export default class MyPluginLogic { message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; - if (data.limit && data.numberOfRolls >= (data.limit -2)) { + if (data.limit && data.numberOfRolls >= (data.limit - 2)) { message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; } } @@ -369,6 +375,7 @@ export default class MyPluginLogic { Since `data` is fetched (normally) by a Obsidian API call, we'll fetch it beforehand and pass it to the function. This will require a small refactor, since you'll need to remove `this.` before `data`. Showing the Notice will stay outside of the extracted function too, to not have any dependency on Obsidian API. All that's left is to use the extracted function in `main.ts`: + ```js // main.ts const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { @@ -379,7 +386,7 @@ const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEven } ``` -It's possible to test the extracted code by i.e. making sure that the limit is respected without providing any mock for Obsidian. +It's possible to test the extracted code by i.e. making sure that the limit is respected without providing any mock for `obsidian`. ```ts // example.spec.ts From 20a5436c939604a35b65fed14abce05a0a32e864 Mon Sep 17 00:00:00 2001 From: s-blu Date: Tue, 14 Jan 2025 19:32:25 +0100 Subject: [PATCH 7/8] use spaces over tabs in code examples, add missing semicolons --- ...w to add automated tests to your plugin.md | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index da1798761..fb4496dfa 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -44,8 +44,8 @@ Jest is now integrated into the project and usable for testing. ```ts // example.spec.ts describe('MyPlugin Tests', () => { - -}) + +}); ``` 3. To the describe, add a test case. Please note that `it` and `test` are aliases, so you can also call `test`, depending on your preference. Read more in [Jests documentation](https://jestjs.io/docs/api#testname-fn-timeout) @@ -100,6 +100,7 @@ Mocking the Obsidian API does not lessen the usefulness of your tests. When test 1. Create a folder named `__mocks__` at the root of your plugin folder 2. In `__mocks__`, create a file called `obsidian.ts` and provide an empty (mock) class for every import listed at the top of `main.ts`: + ```ts // obsidian.ts export class Modal {} @@ -119,15 +120,16 @@ This mock is only enough to enable Jest to read `main.ts` at all. It is insuffic For example, add a second test case below the existing one: ```ts - // example.spec.ts - it('onload should load default settings', async () => { - const plugin = new MyPlugin({} as any, {} as any); - await plugin.onload(); +// example.spec.ts +// [... describe and first test case ...] +it('onload should load default settings', async () => { + const plugin = new MyPlugin({} as any, {} as any); + await plugin.onload(); - expect(plugin.settings).toEqual({ - mySetting: 'default' - }); + expect(plugin.settings).toEqual({ + mySetting: 'default' }); +}); ``` Run it with `npm run test`. It'll fail due to a `TypeError: this.loadData is not a function` error. @@ -189,8 +191,8 @@ Lets assume you want to test following code of `main.ts` (line 19 to 38): // main.ts // This creates an icon in the left ribbon. const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); + // Called when the user clicks the icon. + new Notice('This is a notice!'); }); ``` @@ -242,7 +244,7 @@ import MyPluginLogic from "./myplugin"; // [other code ...] const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - MyPluginLogic.ribbonIconCallback(Notice); + MyPluginLogic.ribbonIconCallback(Notice); }); ``` @@ -330,7 +332,7 @@ export default class MyPlugin extends Plugin { if (this.data.limit && this.data.limit <= this.data.numberOfRolls) { message = "Oh no, you've surpassed your daily limit!"; } else { - const diceRoll = Math.floor(Math.random() * 7); + const diceRoll = Math.floor(Math.random() * 7); this.data.numberOfRolls++; message = `Hey, ${this.data.username}! You've rolled a ${diceRoll} - this was your ${this.data.numberOfRolls} roll today.`; @@ -353,16 +355,16 @@ export default class MyPluginLogic { static generateDiceRollMessage(data: any): string { let message; if (data.limit && data.limit <= data.numberOfRolls) { - message = "Oh no, you've surpassed your daily limit!"; + message = "Oh no, you've surpassed your daily limit!"; } else { - const diceRoll = Math.floor(Math.random() * 7); - data.numberOfRolls++; + const diceRoll = Math.floor(Math.random() * 7); + data.numberOfRolls++; - message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; + message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; - if (data.limit && data.numberOfRolls >= (data.limit - 2)) { - message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; - } + if (data.limit && data.numberOfRolls >= (data.limit - 2)) { + message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; + } } return message; @@ -383,7 +385,6 @@ const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEven const message = MyPluginLogic.generateDiceRollMessage(this.data); new Notice(message); }); -} ``` It's possible to test the extracted code by i.e. making sure that the limit is respected without providing any mock for `obsidian`. @@ -403,7 +404,7 @@ describe("MyPlugin Tests", () => { const message = MyPluginLogic.generateDiceRollMessage(mockData); expect(message).toEqual("Oh no, you've surpassed your daily limit!"); - }) + }); }); ``` From 1eb2916cf4729f4ce52c7e170a6070c71b0ecab2 Mon Sep 17 00:00:00 2001 From: s-blu Date: Tue, 14 Jan 2025 20:41:39 +0100 Subject: [PATCH 8/8] correct block intendation --- .../How to add automated tests to your plugin.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md index fb4496dfa..c363f3e80 100644 --- a/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md +++ b/04 - Guides, Workflows, & Courses/Guides/How to add automated tests to your plugin.md @@ -355,16 +355,16 @@ export default class MyPluginLogic { static generateDiceRollMessage(data: any): string { let message; if (data.limit && data.limit <= data.numberOfRolls) { - message = "Oh no, you've surpassed your daily limit!"; + message = "Oh no, you've surpassed your daily limit!"; } else { - const diceRoll = Math.floor(Math.random() * 7); - data.numberOfRolls++; + const diceRoll = Math.floor(Math.random() * 7); + data.numberOfRolls++; - message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; + message = `Hey, ${data.username}! You've rolled a ${diceRoll} - this was your ${data.numberOfRolls} roll today.`; - if (data.limit && data.numberOfRolls >= (data.limit - 2)) { - message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; - } + if (data.limit && data.numberOfRolls >= (data.limit - 2)) { + message += ` Watch out! You only have ${data.limit - data.numberOfRolls} rolls left today!`; + } } return message;