Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 110 additions & 48 deletions docs/guides/javascript/react/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,34 +43,53 @@ npm test -- --coverage

Test files must match the glob `**/esm/tests/**/*.test.{ts,tsx}`. Place them alongside the source they test:

```
public
└── lib
└── js
└── esm
├── src
│ └── output
│ └── ExampleComponent.tsx
└── tests
└── output
└── ExampleComponent.test.ts
```
import FileTree from '@site/src/components/FileTree';

<FileTree structure={{
"public": {
"lib": {
"js": {
"esm": {
"src": {
"output": {
"ExampleComponent.tsx": "The source file being tested"
}
},
"tests": {
"output": {
"ExampleComponent.test.ts": "The test for ExampleComponent.tsx"
}
}
}
}
}
}
}} />

The same convention applies to plugin components:

```
public
└── mod
└── forum
└── js
└── esm
├── src
│ └── output
│ └── ExampleComponent.tsx
└── tests
└── output
└── ExampleComponent.test.ts
```
<FileTree structure={{
"public": {
"mod": {
"forum": {
"js": {
"esm": {
"src": {
"output": {
"ExampleComponent.tsx": "The source file being tested"
}
},
"tests": {
"output": {
"ExampleComponent.test.ts": "The test for ExampleComponent.tsx"
}
}
}
}
}
}
}
}} />

## Writing a test

Expand All @@ -88,7 +107,47 @@ describe('getString', () => {
});
```

## Mocking AMD modules
## Mocking

Mocking of class and module dependencies of your unit under test is strongly encouraged. In some cases it is mandatory.

Some functionality, such as the ability to mock strings and AMD modules, is provided as part of the Moodle core and these mocks are reset between tests.

You should not need to clean mocks up manually. Each test starts with a fresh state.

You can clear up any additional test state within your test file using:

- `beforeEach()`
- `afterEach()`

See the [Jest Setup and Teardown](https://jestjs.io/docs/setup-teardown) documentation for further information.

### Mocking strings

Because of the way in which Moodle's string module fetches strings using a web service, all strings are mocked to a standard value of:

```
[identifier, component]
```

For cases where your tests expect a specific string value, you can mock values using the global `mockString` method:

```typescript
describe('@moodle/lms/mod_example/Example', () => {
beforeEach(() => {
mockString('dofabuluousthings', 'more_example', 'Do something fabulous!!');
});
it('Renders an Example component', () => {
await act(async() => {
render(<Example />);
});
Comment on lines +140 to +143

expect(screen.getByText('Do something fabulous!!!')).toBeInTheDocument();
});
});
```

### Mocking AMD modules

AMD modules (anything loaded via `requirejs`) **cannot** run inside Jest. The Jest module system and the AMD loader are completely separate environments, so `requirejs`, `M`, jQuery, and other Moodle globals are not available.

Expand All @@ -111,9 +170,9 @@ describe('my component', () => {
});
```

:::note
:::danger[Unmocked AMD modules]

If code under test calls `requireAsync` or `requireManyAsync` with a module that has not been registered via `mockAmdModule`, the test will throw:
If code under test calls `requireAsync` or `requireManyAsync` with a module that has not been registered using `mockAmdModule` then the test will throw an error:

```
Error: Unexpected call to requireAsync with module name: core/notification
Expand All @@ -123,37 +182,40 @@ This is intentional: missing mocks produce a hard failure rather than silent wro

:::

### Registrations reset between tests

Mocks and test fixtures, such as the AMD module map, and the string map, are cleared between each test using the `afterEach` notation.

You do not need to clean up manually. Each test starts with a fresh state.

You can clear up any additional test state within your test file using:
## Handling redirects

- `beforeEach()`
- `afterEach()`
If your code causes the page to redirect, then it must use the `@moodle/lms/core/location` module's `redirect` method, for example:

See the [Jest Setup and Teardown](https://jestjs.io/docs/setup-teardown) documentation for further information.
```typescript
import {redirect} from '@moodle/lms/core/location';

## Mocking language strings
export default function () {
redirect('https://example.com');
}
```

`mockString(identifier, component, resolved)` registers a resolved value for a specific `(identifier, component)` pair. This delegates to the default `core/str` mock that is already registered in `.jest/globalSetup.ts`.
Moodle automatically mocks the `redirect` function and allows you to specify the expected value before calling your method using the `expectRedirect()` helper:

```typescript
mockString('submit', 'core', 'Submit');
mockString('cancel', 'core', 'Cancel');
import Example from '@moodle/lms/mod_example/Example';

describe('@moodle/lms/mod_example/Example', () => {
it('Redirects to the user documentation', () => {
expectRedirect({urlContains: 'example.com'});

await expect(getString('submit', 'core')).resolves.toBe('Submit');
Example();
});
});
```

For any string that was not registered, the default mock returns `[identifier, component]`:
The `expectRedirect` method accepts:

```typescript
await expect(getString('other', 'core')).resolves.toBe('[other, core]');
```
- a `url` parameter with an exact matching URL; or
- a `urlContains` parameter with a partial match.

If a redirect occurs without an `expectRedirect()` call, an Error will be thrown.

This default is useful for snapshot tests and assertions that only care whether a string key was requested, not its exact value.
Expected redirects are reset between tests.

## Module path aliases

Expand Down
87 changes: 87 additions & 0 deletions src/components/FileTree/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Copyright (c) Moodle Pty Ltd.
*
* Moodle is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Moodle is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Moodle. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react';
import CodeBlock from '@theme/CodeBlock';

function parseObjectTree(tree: DirectoryStructure, prefix = ''): string {
const keys = Object.keys(tree);
let result = '';

keys.forEach((key, index) => {
const isLast = index === keys.length - 1;
const pointer = isLast ? '└── ' : '├── ';
const nextPrefix = prefix + (isLast ? ' ' : '│ ');

const value = tree[key];

let comment = '';
let children: DirectoryStructure | undefined;

// Determine the format at runtime
if (value !== null && typeof value === 'object') {
if ('_comment' in value || '_children' in value) {
// It's using the expanded object format
const expanded = value as ExpandedItem;
comment = expanded._comment ? ` # ${expanded._comment}` : ''; // eslint-disable-line no-underscore-dangle
children = expanded._children; // eslint-disable-line no-underscore-dangle
} else {
// It's using the simple nested directory format
children = value as DirectoryStructure;
}
} else if (typeof value === 'string') {
// Inline shorthand string comment support: "filename": "comment string"
comment = ` # ${value}`;
}

// 1. Append the node line
result += `${prefix}${pointer}${key}${comment}\n`;

// 2. Recurse into children if they exist
if (children) {
result += parseObjectTree(children, nextPrefix);
}
});

return result;
}

type FileValue = string | null;

export interface ExpandedItem {
_comment?: string;
_children?: DirectoryStructure;
}

// A node can now be a primitive file, a simple nested directory, or an expanded item
export type TreeItem = FileValue | DirectoryStructure | ExpandedItem;

export interface DirectoryStructure {
[nodeName: string]: TreeItem;
}

export type FileTreeProps = {
structure: DirectoryStructure;
};

export default function FileTree({ structure }: FileTreeProps): JSX.Element {
return (
<CodeBlock>
{parseObjectTree(structure)}
</CodeBlock>
);
}
Loading