Forge Custom UI Testing with Jest

Hi together,

I want ask it is possible to develop an forage app with TDD?

I ask this very stupid question because for me isn’t possible to run test together with the @forge/bridge.

I get always this error if I want use the storageAPI for example:

Cannot find module '@forge/bridge' from 'src/stores/AppStore.ts'

Is Forge the correct way, if I want develop a modern full tested app? Or is it Forge more for playing and not a good idea for production solution, if the simplest stuff is not possible?

Thank you

Hi @bongartz. Let’s separate your problem into two parts: why do you get the “cannot find module” error and is Forge ready for production.

So, for the first one, it seems like you didn’t run npm install (or yarn install if you use yarn). You need to install dependencies and this will let you run jest or any other test runner. Of course, @forge/bridge needs to be declared in your dependencies in package.json. Let me know if this helps.

Second, Forge is production-ready. It is definitely not just for playing - a lot of apps have already been built and developers are building even more. Is it a good idea to try adopting TDD to build a Forge app? Yes, absolutely. You can search CDAC for some topics related to jest, unit testing and Forge - some of these topics are quite interesting.

If you are using jest, you may also need to mock out some of the @forge/bridge methods using jest.mock, since in a production environment, the @forge/bridge will be communicating with the site outside of the iframe, but in a test it is not running within a site.

@Dmitrii, @kchan Thank you for your help.

My problem comes not from “yarn install”, if I run “forge tunnel” the app works fine, with the forge/bridge. Here is the same topic inside the forum, but with no working answer for me:

https://community.developer.atlassian.com/t/testing-forge-custom-ui-components-using-jest/50320

I think the problem is by mocking, but this is one step before to solve I think, because the test can not start because of the Cannot find module '@forge/bridge' from 'src/stores/AppStore.ts'

Here my result from yarn install and from yarn test. Without forge/bridge my coverage ist bei 95%.

 ✝  roadmap/static/roadmap   feature/ROAD-9-setting--general-tab-basics±  yarn
yarn install v1.22.10
[1/4] 🔍  Resolving packages...
success Already up-to-date.
✨  Done in 0.63s.
 ✝  roadmap/static/roadmap   feature/ROAD-9-setting--general-tab-basics  yarn test
yarn run v1.22.10
$ yarn run jest -env=jsdom --collectCoverage
$ /Users/andre/Arbeit/Projekte/jira_pugins/roadmap/static/roadmap/node_modules/.bin/jest -env=jsdom --collectCoverage
 PASS  src/hooks/LocalStorage.test.ts
 PASS  src/stores/AppStore.test.ts
 PASS  src/modules/Timeline/components/TimelineItemCell.test.tsx
 PASS  src/modules/Timeline/index.test.tsx
 PASS  src/modules/Settings/stores/SettingsStore.test.ts

 FAIL  src/containers/Root.test.tsx
  ● Test suite failed to run

    Cannot find module '@forge/bridge' from 'src/components/Nav.tsx'

    Require stack:
      src/components/Nav.tsx
      src/App.tsx
      src/Routes.tsx
      src/containers/Root.tsx
      src/containers/Root.test.tsx

      3 | import routes from '../constants/Routes.json'
      4 | import useLocalStorage from '../hooks/LocalStorage'
    > 5 | import { invoke } from '@forge/bridge'
        | ^
      6 | import EditorSettingsIcon from '@atlaskit/icon/glyph/editor/settings'
      7 | import { fontFamily } from '@atlaskit/theme'
      8 | import { observer } from 'mobx-react-lite'

      at Resolver.resolveModule (node_modules/jest-resolve/build/resolver.js:322:11)
      at Object.<anonymous> (src/components/Nav.tsx:5:1)

 FAIL  src/Routes.test.tsx
  ● Test suite failed to run

    Cannot find module '@forge/bridge' from 'src/components/Nav.tsx'

    Require stack:
      src/components/Nav.tsx
      src/App.tsx
      src/Routes.tsx
      src/Routes.test.tsx

      3 | import routes from '../constants/Routes.json'
      4 | import useLocalStorage from '../hooks/LocalStorage'
    > 5 | import { invoke } from '@forge/bridge'
        | ^
      6 | import EditorSettingsIcon from '@atlaskit/icon/glyph/editor/settings'
      7 | import { fontFamily } from '@atlaskit/theme'
      8 | import { observer } from 'mobx-react-lite'

      at Resolver.resolveModule (node_modules/jest-resolve/build/resolver.js:322:11)
      at Object.<anonymous> (src/components/Nav.tsx:5:1)

 PASS  src/containers/SettingsPage.test.tsx (5.491 s)
 PASS  src/modules/Settings/index.test.tsx (5.556 s)
 PASS  src/modules/Settings/views/SettingsGeneral.test.tsx (5.696 s)
---------------------------------|---------|----------|---------|---------|-------------------
File                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------------------|---------|----------|---------|---------|-------------------
All files                        |    61.9 |    33.33 |   57.14 |   62.29 |                   
 src                             |       0 |      100 |       0 |       0 |                   
  App.tsx                        |       0 |      100 |       0 |       0 | 9-11              
  Routes.tsx                     |       0 |      100 |       0 |       0 | 7-30              
  main.tsx                       |       0 |      100 |     100 |       0 | 8                 
 src/components                  |       0 |        0 |       0 |       0 |                   
  Nav.tsx                        |       0 |        0 |       0 |       0 | 16-57             
 src/containers                  |      75 |      100 |   66.66 |   85.71 |                                  
  Root.tsx                       |       0 |      100 |       0 |       0 | 4                 
  SettingsPage.tsx               |     100 |      100 |     100 |     100 |                   
 src/hooks                       |   46.15 |       25 |   66.66 |   46.15 |                   
  LocalStorage.ts                |   46.15 |       25 |   66.66 |   46.15 | 14-15,21-31       
 src/modules/Settings            |      75 |      100 |   66.66 |      75 |                   
  index.tsx                      |      75 |      100 |   66.66 |      75 | 21                
 src/modules/Settings/stores     |      60 |      100 |   33.33 |      60 |                   
  SettingsStore.ts               |      60 |      100 |   33.33 |      60 | 41-50             
 src/modules/Settings/views      |   81.81 |        0 |   77.77 |   81.81 |                   
  SettingsGeneral.tsx            |   81.81 |        0 |   77.77 |   81.81 | 61,81             
 src/modules/Timeline            |   83.33 |    66.66 |   83.33 |   83.33 |                   
  index.tsx                      |   83.33 |    66.66 |   83.33 |   83.33 | 32-35                         
 src/services                    |     100 |      100 |     100 |     100 |                   
  helper.ts                      |     100 |      100 |     100 |     100 |                   
 src/stores                      |     100 |      100 |     100 |     100 |                   
  AppStore.ts                    |     100 |      100 |     100 |     100 |                   
---------------------------------|---------|----------|---------|---------|-------------------
Jest: "global" coverage threshold for statements (80%) not met: 61.9%
Jest: "global" coverage threshold for branches (65%) not met: 33.33%
Jest: "global" coverage threshold for lines (80%) not met: 62.29%
Jest: "global" coverage threshold for functions (80%) not met: 57.14%

Test Suites: 2 failed, 10 passed, 12 total
Tests:       15 passed, 15 total
Snapshots:   0 total
Time:        9.951 s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

here is my /package.jsion

"devDependencies": {
    "babel-jest": "^27.2.2",
    "eslint": "^7.32.0",
    "eslint-plugin-react-hooks": "^2.1.2",
    "jest": "^27.2.2",
    "jest-circus": "^27.2.2",
    "jest-scss-transform": "^1.0.1",
    "jest-watch-typeahead": "^0.6.4",
    "@babel/core": "^7.15.5",
    "@babel/preset-env": "^7.15.6",
    "@babel/preset-react": "^7.14.5",
    "babel-preset-react-app": "^10.0.0"
  },
  "dependencies": {
    "@forge/resolver": "^1.3.5",
    "@types/jest": "^27.0.2",
    "ts-jest": "^27.0.5"
  },

and here is my /static/project/package.json

"dependencies": {
    "@atlaskit/button": "^16.1.2",
    "@atlaskit/css-reset": "^6.1.4",
    "@atlaskit/dropdown-menu": "^10.1.9",
    "@atlaskit/form": "^8.4.1",
    "@atlaskit/icon": "^21.9.0",
    "@atlaskit/select": "^15.2.2",
    "@atlaskit/tabs": "^13.2.2",
    "@atlaskit/textarea": "^4.2.2",
    "@atlaskit/textfield": "^5.1.2",
    "@atlaskit/theme": "^12.0.0",
    "@forge/api": "^2.3.0",
    **"@forge/bridge": "^2.1.1",**
    "@tailwindcss/ui": "^0.7.2",
    "@types/jest": "^27.0.2",
    "mobx": "^6.3.3",
    "mobx-persist-store": "^1.0.4",
    "mobx-react-lite": "^3.2.1",
    "react": "^17.0.0",
    "react-dom": "^17.0.0",
    "react-draggable": "^4.4.4",
    "react-indiana-drag-scroll": "^2.0.1",
    "react-router-dom": "^5.3.0",
    "ts-jest": "^27.0.5"
  },
  "devDependencies": {
    "@babel/core": "^7.15.5",
    "@babel/preset-env": "^7.15.6",
    "@babel/preset-react": "^7.14.5",
    "@testing-library/dom": "^8.6.0",
    "@testing-library/jest-dom": "^5.14.1",
    "@testing-library/react": "^12.1.0",
    "@testing-library/react-hooks": "^7.0.2",
    "@testing-library/user-event": "^13.2.1",
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "@types/react-router-dom": "^5.3.0",
    "@typescript-eslint/eslint-plugin": "^4.0.0",
    "@typescript-eslint/parser": "^4.0.0",
    "@vitejs/plugin-react": "^1.0.0",
    "autoprefixer": "^10.3.7",
    "babel-eslint": "^10.0.0",
    "babel-jest": "^27.2.2",
    "babel-preset-react-app": "^10.0.0",
    "eslint": "^8.0.1",
    "eslint-config-prettier": "^8.3.0",
    "eslint-config-react-app": "^6.0.0",
    "eslint-plugin-flowtype": "^5.2.0",
    "eslint-plugin-import": "^2.22.0",
    "eslint-plugin-jsx-a11y": "^6.3.1",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-react": "^7.20.3",
    "eslint-plugin-react-hooks": "^4.0.8",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^27.2.2",
    "jest-circus": "^27.2.2",
    "jest-scss-transform": "^1.0.1",
    "jest-watch-typeahead": "^0.6.4",
    "postcss": "^8.3.8",
    "prettier": "^2.4.1",
    "react-test-renderer": "^17.0.2",
    "sass": "^1.42.1",
    "tailwindcss": "^2.2.16",
    "typescript": "^4.3.2",
    "vite": "^2.6.3"
  },

And a picture from my /static/project/node_modules folder

It looks like your dependencies are all correct. Perhaps it’s more to do with the jest set up. Where are you running your jest command from? Is it from within static/project/?

Have you tried mocking @forge/bridge? The error isn’t really what I would expect without mocking it, but since you likely have to do it anyway, it may be worth trying.

@kchan Yes I run the test from my static/project folder

I found something like jest Cann’t work with @ modules. I find some stuff but at moment nothing is working for me:

I try to map this but without some effort:

Jest config

"moduleNameMapper": 
      {"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy",
      "^@forge/(.*)$": "<rootDir>/node_modules/@forge/$1"
    }

error message

 Configuration error:
    
    Could not locate module @forge/bridge mapped as:
    /Users/andre/Arbeit/Projekte/jira_pugins/roadmap/static/roadmap/node_modules/@forge/$1.
    
    Please check your configuration for these entries:
    {
      "moduleNameMapper": {
        "/^@forge\/(.*)$/": "/Users/andre/Arbeit/Projekte/jira_pugins/roadmap/static/roadmap/node_modules/@forge/$1"
      },
      "resolver": undefined
    }

      3 | import routes from '../constants/Routes.json'
      4 | import useLocalStorage from '../hooks/LocalStorage'
    > 5 | import { invoke } from '@forge/bridge'

by the way I try to mock @forge/brigde but the code crash before the mock will use.

@bongartz you shouldn’t need to use the moduleNameMapper for this.

You can create a mock with __mocks__/@forge/bridge/index.ts with contents like:

// Depending on what functionality you need your mock to have:
export const requestConfluence = jest.fn();

And then in your test you can use it like:

import { postConfluence } from './my-bridge-user';

import { requestConfluence } from '@forge/bridge';

describe('postConfluence', () => {
  it('posts', async () => {
    await postConfluence('/confluence/path');

    expect(requestConfluence).toHaveBeenCalledWith('/confluence/path', {
      method: 'POST',
    });
  });
});

where my-bridge-user.ts looks like:

import { requestConfluence } from '@forge/bridge';

export function postConfluence(path: string) {
  return requestConfluence(path, { method: 'POST' });
}

1 Like

@RyanBraganza

Wow thank you. I added my mock to the root and here it doesn’t have an effect.

But know my error changed, because of the mock.

 RUNS  src/modules/Settings/api/SettingsRemoteManager.test.ts
node:internal/process/promises:246
          triggerUncaughtException(err, true /* fromPromise */);
          ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "Error: SettingsRemoteManger: loading with key not successfully for key: settings".] {
  code: 'ERR_UNHANDLED_REJECTION'
}

Here my mock from __mocks__/@forge/bridge/index.ts

export const invoke = jest.fn((key) => console.log('key', key))

my test

it('should loadForKey and deliver an error', async () => {
    const sut = new SettingsRemoteManager()
    const key = 'settings'
    try {
      sut.loadForKey(key)
    } catch (error) {
      expect(error).not.toEqual(null)
    }

    expect(() => sut.loadForKey('settings')).toThrowError(
      new Error(
        `SettingsRemoteManger: loading with key not successfully: ${key}`
      )
    )
  })

and my function for testing, in which to invoke is calling

async loadForKey(key: string): Promise<TRoadmapDescription> {
    try {
      const result: TRoadmapDescription = await invoke(key)

      if (
        typeof result.title === 'string' &&
        typeof result.description === 'string'
      ) {
        return result
      } else {
        throw new Error(`SettingsRemoteManger: result not valid for: ${key}`)
      }
    } catch (error) {
      throw new Error(
        `SettingsRemoteManger: loading with key not successfully for key: ${key}`
      )
    }
  }

Thank you

For that error I think you’re just missing an await in front of sut.loadForKey(key) inside your test.

@RyanBraganza

No change :frowning:

it('should loadForKey and deliver an error', async () => {
    const sut = new SettingsRemoteManager()
    const key = 'settings'
    try {
      await sut.loadForKey(key)
    } catch (error) {
      expect(error).not.toEqual(null)
    }

    expect(async () => await sut.loadForKey('settings')).toThrowError(
      new Error(
        `SettingsRemoteManger: loading with key not successfully: ${key}`
      )
    )
  })

try this:

  const sut = new SettingsRemoteManager();
  const key = 'settings';
  try {
    await sut.loadForKey(key);
  } catch (error) {
    expect(error).not.toEqual(null);
  }

  await expect(() => sut.loadForKey('settings')).rejects.toEqual(
    new Error(`SettingsRemoteManger: loading with key not successfully for key: ${key}`)
  );
});
1 Like

@RyanBraganza Thank this works perfect.

But my mock function returns always undefined

export const invoke = jest.fn().mockImplementation((key) => key)

You haven an idea why? I have the feeling this ins’t called. But if I rename the mock to 2__mock__ the module not found error comes back.