1. Project Overview & Quickstart (MetaMask/metamask-extension)
File: README.md
MetaMask Browser Extension
You can find the latest version of MetaMask on our official website. For help using MetaMask, visit our User Support Site.
For general questions, feature requests, or developer questions, visit our Community Forum.
MetaMask supports Firefox, Google Chrome, and Chromium-based browsers. We recommend using the latest available browser version.
For up to the minute news, follow us on X.
To learn how to develop MetaMask-compatible applications, visit our Developer Docs.
To learn how to contribute to the MetaMask codebase, visit our Contributor Docs.
To learn how to contribute to the MetaMask Extension project itself, visit our Extension Docs.
GitHub Codespaces quickstart
As an alternative to building on your local machine, there is a new option to get a development environment up and running in less than 5 minutes by using GitHub Codespaces. Please note that there is a Limited Free Monthly Quota, and after that GitHub will start charging you.
Note: You are billed for both time spent running, and for storage used
- Start by clicking the button above
- A new browser tab will open with a remote version of Visual Studio Code (this will take a few minutes to load)
- A "Simple Browser" will open inside the browser with noVNC -- click Connect
- Optional steps:
- Click the button at the upper-right of the Simple Browser tab to open the noVNC window in its own tab
- Open the noVNC sidebar on the left, click the gear icon, change the Scaling Mode to Remote Resizing
- Optional steps:
- Wait about 20 extra seconds on the first launch, for the scripts to finish
- Right-click on the noVNC desktop to launch Chrome or Firefox with MetaMask pre-installed
- Change some code, then run
yarn startto build in dev mode - After a minute or two, it will finish building, and you can see your changes in the noVNC desktop
Tips to keep your Codespaces usage lower
- You are billed for both time spent running, and for storage used
- Codespaces pause after 30 minutes of inactivity, and auto-delete after 30 days of inactivity
- You can manage your Codespaces here: https://github.com/codespaces
- You may want to manually pause them before the 30 minute timeout
- If you have several idle Codespaces hanging around for several days, you can quickly run out of storage quota. You should delete the ones you do not plan to use anymore, and probably keep only 1 or 2 in the long-term. It's also possible to re-use old Codespaces and switch the branch, instead of creating new ones and deleting the old ones.
Codespaces on a fork
If you are not a MetaMask Internal Developer, or are otherwise developing on a fork, the default Infura key will be on the Free Plan and have very limited requests per second. If you want to use your own Infura key, follow the .metamaskrc and INFURA_PROJECT_ID instructions in the section Building on your local machine.
Building on your local machine
Install Node.js version 24
- If you are using nvm (recommended) running
nvm usewill automatically choose the right node version for you.
- If you are using nvm (recommended) running
Enable Corepack by executing the command
corepack enablewithin the metamask-extension project. Corepack is a utility included with Node.js by default. It manages Yarn on a per-project basis, using the version specified by thepackageManagerproperty in the project's package.json file. Please note that modern releases of Yarn are not intended to be installed globally or via npm.Duplicate
.metamaskrc.distwithin the root and rename it to.metamaskrcby runningcp .metamaskrc{.dist,}.- Replace the
INFURA_PROJECT_IDvalue with your own personal Infura API Key.- If you don't have an Infura account, you can create one for free on the Infura website.
- If debugging MetaMetrics, you'll need to add a value for
SEGMENT_WRITE_KEYSegment write key, see Developing on MetaMask - Segment. - If debugging unhandled exceptions, you'll need to add a value for
SENTRY_DSNSentry Dsn, see Developing on MetaMask - Sentry. - Optionally, replace the
PASSWORDvalue with your development wallet password to avoid entering it each time you open the app. - If developing with remote feature flags, and you want to override the flags in the build process, you can add a
.manifest-overrides.jsonfile to the root of the project and setMANIFEST_OVERRIDES=.manifest-overrides.jsonin.metamaskrcto the path of the file.
This file is used to add flags tomanifest.jsonbuild files for the extension. You can also modify the_flags.remoteFeatureFlagsin the built version ofmanifest.jsonin thedist/browserfolder to tweak the flags after the build process (these changes will get overwritten when you build again).
An example of this remote feature flag overwrite could be:
json{ "_flags": { "remoteFeatureFlags": { "testBooleanFlag": false } } }- Replace the
Run
yarn installto install the dependencies.Build the project to the
./dist/folder withyarn dist(for Chromium-based browsers) oryarn dist:mv2(for Firefox)- Optionally, to create a development build you can instead run
yarn start(for Chromium-based browsers) oryarn start:mv2(for Firefox) - Uncompressed builds can be found in
/dist, compressed builds can be found in/buildsonce they're built. - See the build system readme for build system usage information.
- Optionally, to create a development build you can instead run
Follow these instructions to verify that your local build runs correctly:
AI Agent Skills (`yarn skills`)
AI coding agents (Cursor, Claude Code, Codex) consume shared skills from the MetaMask/skills repo, with an optional private overlay from Consensys/skills. Per ADR #57 this content is not committed here โ yarn skills syncs it on demand into local-only paths under .cursor/, .claude/, and .agents/.
Zero-config setup:
yarn install # refreshes the MetaMask/skills cache via the shared `@metamask/skills` CLI
yarn skills # syncs all default skills through the `metamask-skills sync` commandOptional local configuration:
cp .skills.local.example .skills.local
# edit .skills.local to set SKILLS_DOMAINS or override skills source paths
yarn skills --select # interactively pick domains
SKILLS_DOMAINS=perps,testing yarn skills # one-off domain overrideUse .skills.local for persistent skills configuration. Shell environment variables with the same names are supported for one-off or CI overrides and take precedence.
Skipping yarn skills is fine โ it only affects agent tooling, not the app build. The repo uses the shared @metamask/skills package so sync/cache behavior stays uniform across MetaMask packages. To opt into best-effort regeneration during install/setup, set SKILLS_AUTO_UPDATE=1 in your shell or .skills.local.
Git Hooks
To get quick feedback from our shared code quality fitness functions before committing the code, you can install our git hooks with Husky.
$ yarn githooks:install
You can read more about them in our testing documentation.
If you are using VS Code and are unable to make commits from the source control sidebar due to a "command not found" error, try these steps from the Husky docs.
Contributing
Development builds
To start a development build (e.g. with logging and file watching) run yarn start.
Development build with wallet state
You can start a development build with a preloaded wallet state, by adding TEST_SRP='' and PASSWORD='' to the .metamaskrc file. Then you have the following options:
- Start the wallet with the default fixture flags, by running
yarn start:with-state. - Check the list of available fixture flags, by running
yarn start:with-state --help. - Start the wallet with custom fixture flags, by running
yarn start:with-state --FIXTURE_NAME=VALUEfor exampleyarn start:with-state --withAccounts=100. You can pass as many flags as you want. The rest of the fixtures will take the default values.
Advanced Webpack builds
yarn start is backed by Webpack. You can also call yarn webpack directly for advanced build-system debugging; see the Webpack README for more information.
yarn start:lavamoat is not currently supported because Webpack watch mode does not support LavaMoat. For production-like LavaMoat verification, use yarn webpack:lavamoat:build or yarn build:test.
React and Redux DevTools
To use React or Redux DevTools you'll first need to set METAMASK_REACT_REDUX_DEVTOOLS to true in .metamaskrc.
To start the React DevTools, run yarn devtools:react with a development build installed in a browser. This will open in a separate window; no browser extension is required.
To start the Redux DevTools Extension:
- Install the package
remotedev-serverglobally (e.g.yarn global add remotedev-server) - Install the Redux Devtools extension.
- Open the Redux DevTools extension and check the "Use custom (local) server" checkbox in the Remote DevTools Settings, using the default server configuration (host
localhost, port8000, secure connection checkbox unchecked).
Then run the command yarn devtools:redux with a development build installed in a browser. This will enable you to use the Redux DevTools extension to inspect MetaMask.
To create a development build and run both of these tools simultaneously, run yarn start:dev.
Test Dapp
This test site can be used to execute different user flows.
Running Unit Tests and Linting
Run unit tests and the linter with yarn test. To run just unit tests, run yarn test:unit.
You can run the linter by itself with yarn lint, and you can automatically fix some lint problems with yarn lint:fix. You can also run these two commands just on your local changes to save time with yarn lint:changed and yarn lint:changed:fix respectively.
For Jest debugging guide using Node.js, see docs/tests/jest.md.
Running E2E Tests
Our e2e test suite can be run on either Firefox or Chrome. Here's how to get started with e2e testing:
Preparing a Test Build
Before running e2e tests, ensure you've run yarn install to download dependencies. Next, you'll need a test build. You have 3 options:
- Use
yarn download-builds --build-type testto quickly download and unzip test builds for Chrome and Firefox into the./dist/folder. This method is fast and convenient for standard testing. - Create a custom production-like Webpack test build. This command allows you to generate test builds for various types, including:
yarn build:testfor main buildyarn build:test:flaskfor flask buildyarn build:test:mv2for mv2 build
- Start a Webpack test build with live changes:
yarn start:testis particularly useful for development. It starts a test build that automatically recompiles application code upon changes. This option is ideal for iterative testing and development. This command also allows you to generate test builds for various types, including:yarn start:testfor main buildyarn start:test:flaskfor flask buildyarn start:test:mv2for mv2 build
Note: The yarn start:test command has LavaMoat and Snow disabled for faster iteration. Use yarn build:test for production-like LavaMoat verification.
Running Tests
Once you have your test build ready, choose the browser for your e2e tests:
- For Firefox, run
yarn test:e2e:firefox.- Note: If you are running Firefox as a snap package on Linux, ensure you enable the appropriate environment variable:
FIREFOX_SNAP=true yarn test:e2e:firefox
- Note: If you are running Firefox as a snap package on Linux, ensure you enable the appropriate environment variable:
- For Chrome, run
yarn test:e2e:chrome.
These scripts support additional options for debugging. Use --helpto see all available options.
Specs that have been migrated from Selenium to Playwright (*.pw.spec.ts, see test/e2e/playwright/README.md) run through their own runner:
- For Firefox, run
yarn test:e2e:playwright:firefox. - For Chrome, run
yarn test:e2e:playwright:chrome.
One-time setup for the Playwright runner (per machine, and again whenever the Playwright version changes):
yarn # installs the Playwright package
yarn playwright install chromium firefox # downloads the browser binariesRunning a single e2e test
Single e2e tests can be run with yarn test:e2e:single test/e2e/tests/TEST_NAME.spec.js along with the options below.
--browser Set the browser to be used; specify 'chrome', 'firefox', 'all'
or leave unset to run on 'all' by default.
[string] [default: 'all']
--debug Run tests in debug mode, logging each driver interaction
[boolean] [default: true]
--retries Set how many times the test should be retried upon failure.
[number] [default: 0]
--leave-running Leaves the browser running after a test fails, along with
anything else that the test used (the local node, the
test dapp, etc.) [boolean] [default: false]
--update-snapshot Update E2E test snapshots
[alias: -u] [boolean] [default: false]For example, to run the account-details tests using Chrome, with debug logging and with the browser set to remain open upon failure, you would use:yarn test:e2e:single test/e2e/tests/account-menu/account-details.spec.js --browser=chrome --leave-running
For migrated Playwright specs, pass the spec path as a positional filter to the Playwright runner instead:
# Run a single spec
yarn test:e2e:playwright:chrome test/e2e/tests/settings/terms-of-use.pw.spec.ts
# Run headless
PLAYWRIGHT_HEADLESS=1 yarn test:e2e:playwright:chrome test/e2e/tests/settings/terms-of-use.pw.spec.ts
# Debug with the Playwright Inspector (step through each driver action)
PWDEBUG=1 yarn test:e2e:playwright:chrome test/e2e/tests/settings/terms-of-use.pw.spec.tsRunning E2E tests with feature flags
Feature flags used in tests fall into two categories: remote flags (runtime values fetched from client-config) and build-time flags (compile-time values set during the build). Use the workflow that matches the flag type.
Remote feature flags (runtime)
Remote flags are provided at runtime and should usually follow production defaults from the Feature Flag Registry. You do not need a custom build just to change a remote flag value for a test.
Use one of these test-time overrides:
manifestFlagsinwithFixturesโ best for simple, test-wide flag overrides that don't depend on other fixture state.FixtureBuilder.withRemoteFeatureFlags()โ best when you need the flag change to interact with other fixture state. Note: this method is only available on the legacyFixtureBuilder; use this builder when you need it, even for new tests.
For code examples and detailed guidelines, see Feature flags in E2E tests in contributor-docs.
Build-time feature flags (compile-time)
Build-time flags are set before running tests and require creating a test build with the flag enabled. Set the flag either in your local .metamaskrc file or as an environment variable prefix (e.g. MULTICHAIN=1 yarn build:test), then follow the steps in Preparing a Test Build to create and run the build.
Feature Flag Registry
The Feature Flag Registry is the central source of truth for all remote feature flags used in MetaMask Extension E2E tests. A CI check runs on every PR to verify that every remote flag reference in changed files exists in the registry. For background on how remote feature flags work in MetaMask, see the Remote Feature Flags contributor doc.
Registry entry format
Each entry in test/e2e/feature-flags/feature-flag-registry.ts has the following shape:
myNewFlag: {
name: 'myNewFlag',
type: FeatureFlagType.Remote,
inProd: true,
productionDefault: false,
status: FeatureFlagStatus.Active,
},| Field | Description |
|---|---|
name |
Must match the object key exactly |
type |
FeatureFlagType.Remote (fetched at runtime) or FeatureFlagType.Build (compile-time) |
inProd |
Whether the flag currently exists in the production client-config API |
productionDefault |
Any valid JSON value (Json type โ boolean, number, string, null, object, or array) |
status |
FeatureFlagStatus.Active or FeatureFlagStatus.Deprecated |
Add a flag to the registry
For the full process of creating a remote feature flag (LaunchDarkly setup, code integration), see the Remote Feature Flags contributor doc. Once the flag exists, register it for E2E tests:
- Look up the flag's current production value from the client-config API. If the flag is not yet in production, set
inProd: falseandproductionDefaultto the intended default. - Add an entry to
test/e2e/feature-flags/feature-flag-registry.tsin alphabetical order. - If you access the flag via a constant (e.g.
remoteFeatureFlags[MY_CONSTANT]), also add the constant mapping to.github/scripts/known-feature-flag-constants.mtsso the CI check can resolve it.
Override flags in E2E tests
To test behavior with a flag value different from the production default, see the override examples in Remote feature flags (runtime) above.
The registry module also exports helper functions for use in tests and tooling (e.g. getProductionRemoteFlagApiResponse(), getProductionRemoteFlagDefaults()). See the JSDoc in feature-flag-registry.ts for details.
Remove a flag
When a remote feature flag is fully rolled out or no longer needed:
- Remove all references to the flag from application code (
app/,ui/,shared/). - Either remove the entry from the registry, or change its
statustoFeatureFlagStatus.Deprecatedif you want to track it temporarily. - The CI check will post a warning on PRs that remove the last codebase reference to a registered flag, reminding you to clean up the registry.
Running E2E performance benchmarks
E2E performance benchmarks measure flow timings (onboarding, send, swap, asset details, etc.) and can be run locally or in CI. Use yarn test:e2e:benchmark with --preset <name> or a file path. See test/e2e/benchmarks/flows/README.md for presets and options.
Running specific builds types e2e test
Different build types have different e2e tests sets. In order to run them look in the package.json file. You will find:
"test:e2e:chrome:snaps": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js --snaps",
"test:e2e:firefox": "SELENIUM_BROWSER=firefox node test/e2e/run-all.js",Test and iterate on GitHub Actions more quickly
Running the full workflow on GitHub Actions can take 30 minutes or more, but there are ways to speed it up for faster iteration
- Automatic build reuse โ CI automatically detects when a PR's build-affecting source files haven't changed compared to a prior run (on the same branch or the base branch). When a match is found, it reuses the existing build artifacts instead of rebuilding, saving ~4 minutes for the webpack builds. This happens transparently with no action needed from you.
[force-builds]in the last commit message, or aforce-buildslabel on the PR โ Forces fresh builds even when CI would otherwise reuse prior artifacts. Useful when you need to verify that builds work after changing only non-code files (CI configs, docs, etc.), or if the automatic system is making a mistake.[skip-builds]in the last commit message, or askip-buildslabel on the PR โ Reuses builds from the most recent prior run without verifying the source hash. This is the fastest option for iterating on tests or non-build changes, but it blocks merging โ you must remove the tag/label and push again before the PR can enter the merge queue.
- Automatic E2E skipping โ CI automatically skips E2E tests when the PR's changes don't require them (e.g., docs-only, CI-only, or test-only changes with reused builds). You can override this in both directions:
[skip-e2e]in the last commit message, or askip-e2elabel on the PR โ Forces E2E tests to be skipped regardless of what files changed.[force-e2e]in the last commit message, or aforce-e2elabel on the PR โ Forces E2E tests to run even when CI would otherwise skip them. Useful for CI-only PRs that modify E2E workflow files.
[skip-unit]in the last commit message (command not working yet, coming soon) - Skips the unit test suitetrigger-ci-*as the branch name - This allows you to run the CI workflow without attaching it to a PR. This is useful if you need to test some things that you know will never be merged. Please clean up after yourself when you're done, and delete the branch.
Changing dependencies
Whenever you change dependencies (adding, removing, or updating, either in package.json or yarn.lock), there are various files that must be kept up-to-date.
yarn.lock:- Run
yarnagain after your changes to ensureyarn.lockhas been properly updated. - Run
yarn lint:lockfile:dedupe:fixto remove duplicate dependencies from the lockfile.
- Run
- The
allow-scriptsconfiguration inpackage.json- Run
yarn allow-scripts autoto update theallow-scriptsconfiguration automatically. This config determines whether the package's install/postinstall scripts are allowed to run. Review each new package to determine whether the install script needs to run or not, testing if necessary. - Unfortunately,
yarn allow-scripts autowill behave inconsistently on different platforms. macOS and Windows users may see extraneous changes relating to optional dependencies.
- Run
- The LavaMoat policy files
- If you are a MetaMask team member and your PR is on a repository branch, you can use the bot command
@metamaskbot update-policiesto ask the MetaMask bot to automatically update the policies for you. - If your PR is from a fork, you can ask a MetaMask team member to help with updating the policy files.
- Manual update instructions: The tl;dr is to run
yarn lavamoat:autoto update these files, but there can be devils in the details:- The webpack app LavaMoat policy files live under
lavamoat/webpack/*/policy.jsonand are re-generated byyarn lavamoat:auto.- These should be regenerated whenever the production dependencies for the app change.
- Whenever you regenerate a policy file, review the changes to determine whether the access granted to each package seems appropriate.
- Unfortunately,
yarn lavamoat:autowill behave inconsistently on different platforms.
macOS and Windows users may see extraneous changes relating to optional dependencies. - If you keep getting policy failures even after regenerating the policy files, try regenerating the policies after a clean install by doing:
rm -rf node_modules/ && yarn && yarn lavamoat:auto
- Keep in mind that any kind of dynamic import or dynamic use of globals may elude LavaMoat's static analysis.
Refer to the LavaMoat documentation or ask for help if you run into any issues.
- The webpack app LavaMoat policy files live under
- If you are a MetaMask team member and your PR is on a repository branch, you can use the bot command
- The Attributions file
- If you are a MetaMask team member and your PR is on a repository branch, you can use the bot command
@metamaskbot update-attributionsto ask the MetaMask bot to automatically update the attributions file for you. - Manual update: run
yarn attributions:generate.
- If you are a MetaMask team member and your PR is on a repository branch, you can use the bot command
Architecture
- Visual of the controller hierarchy and dependencies as of summer 2022.
- Visual of the entire codebase.
Other Docs
- How to add a new translation to MetaMask
- Publishing Guide
- How to use the TREZOR emulator
- Developing on MetaMask
- How to generate a visualization of this repository's development
- How to add new confirmations
- Browser support guidelines
Dapp Developer Resources
- Extend MetaMask's features w/ MetaMask Snaps.
- Prompt your users to add and switch to a new network.
- Change the logo that appears when your dapp connects to MetaMask.
File: docs/README.md
Documentation
These docs relate to how to contribute to the MetaMask project itself.
You can find the latest version of MetaMask on our official website.
For help using MetaMask, visit our User Support Site.
For up to the minute news, follow our Twitter or Medium pages.
To learn how to develop MetaMask-compatible applications, visit our Developer Docs.
- How to add custom build to Chrome
- How to add custom build to Firefox
- A/B testing guide
- Publishing Guide
- How to add a feature behind a secret feature flag
- Developing on MetaMask
LLM Agent Tooling
- LLM Workflow for Extension Testing - Playwright-based workflow for LLM agents to build, launch, and interact with the MetaMask extension
2. Official Technical Reference & Guides (MetaMask/contributor-docs)
File: README.md
MetaMask Contributor Documentation
Welcome! Here you can find the principles, policies, and practices which are followed by MetaMask engineers in building MetaMask. These resources are designed to enable all builders, whether internal or external, in making meaningful contributions to any part of the MetaMask ecosystem.
This is a living repository โ nothing is set in stone! If you're member of MetaMask and are interested in making updates to this repository, make sure to consult the Contributing guide.
Table of Contents
- Contributor Code of Conduct
- Engineering Principles
- JavaScript Guidelines
- Migrations Best Practices
- Performance Alert Guidelines
- Performance Dashboard Guidelines
- Performance Tracing Guidelines
- Pull Requests Guide
- React Guidelines
- Redux Guidelines
- Remote Feature Flags
- Secure Coding Guidelines
- Secure Development Lifecycle Policy
- Testing Guidelines
- TypeScript Guidelines
- Deep links Guidelines
File: docs/testing/e2e/extension-e2e-guidelines.md
MetaMask Extension E2E Test Guidelines
TL;DR
- Manage test size and execution time
- Craft reliable element locators using data-testid
- Avoid using arbitrary delays, leverage wait commands
- Write repeatable tests that produce the same result every time
- Explicitly state assertions
- Enhance test stability with request mocking
- Utilize Page Object Model (POM) for streamlined test automation
Key
โ Recommendโ ๏ธ Use with caution, see notes โ Avoid
Managing Test Size and Execution Time
To maintain efficient and effective E2E testing within MetaMask Extension repository, it's crucial to address the execution time of our spec files. Here are key considerations:
Limiting Tests per Spec File :
It's advisable to avoid overcrowding spec files with too many tests. A high number of tests within a single spec file can lead to longer execution times and complicate the process of identifying and isolating failures.Optimizing Test Execution Time :
Aiming for each spec file's runtime to be under approximately 2 minutes ensures tests are executed swiftly, facilitating quicker feedback loops. This time frame helps in maintaining a balance between comprehensive testing and efficient use of testing resources.Rationale
The structure of spec files plays a pivotal role in our testing strategy, especially considering the functionality provided by CircleCI. CircleCI treats each spec file as the smallest unit for splitting and retrying tests. Consequently, if a spec file contains 10 tests and one fails, all 10 tests must be retried, impacting the efficiency of our testing process. This approach also applies to the "Rerun failed tests" feature.
While it's theoretically possible to split tests by individual cases on CircleCI, such an approach requires significant effort and is not currently implemented. By optimizing the number of tests per spec file and their execution time, we can enhance the overall efficiency and effectiveness of our E2E testing.
Element locators
Crafting resilient locators is crucial for reliable tests. Itโs important to write selectors that are resilient to changes in the extension's UI. Tests become less prone to issues as the extension is updated, for example, during UI redesigns. This results in less effort required to maintain and update the tests, improving the stability of the tests and reducing the associated maintenance costs. Element locators should be independent of CSS or JS so that they do not break on the slightest UI change. Another consideration is whether a test should fail if the content of an element changes. It's worth noting that in the testing framework, element locators extend beyond simple CSS selector strings. They can also be objects or methods that return strings or objects, providing flexible and resilient ways to identify elements.
Guidelines
โ Resilient to changes
await driver.clickElement('[data-testid="page-container-footer-next"]');โ Ok to check the expected text as it is part of the user's experience
await driver.clickElement({ text: 'Next', tag: 'button' });โ ๏ธ Coupled with styling: changes to how buttons are styled will cause this locator to fail
await driver.clickElement('.btn-primary');โ Chaining direct descendants and using indexes: changes to one element position in the chain will cause this locator to fail
await driver.clickElement('footer > button:nth-of-type(2)');โ Xpath locators tend to be slower and more difficult to understand than CSS
await driver.clickElement('//*[@data-testid="page-container-footer-next"]')';Here are some examples how to improve selectors following above guidelines:
// current element locator:
.qr-code__wrapper
// recommended element locator: replace CSS locator with a data-testid
'[data-testid="account-details-qr-code"]'
// current element locator:
'//div[contains(@class, 'home-notification__text') and contains(text(), 'Backup your Secret Recovery Phrase to keep your wallet and funds secure')]'
// recommended element locator: replace XPATH with a query
'{ text: Backup your Secret Recovery Phrase to keep your wallet and funds secure, tag: div }'Wait for commands
Some steps in an end-to-end test require a condition to be met before running. It is tempting to use "sleep" calls, which cause the test to pause execution for a fixed period of time. This approach is inefficient: sleeping for too long may unnecessarily lengthen the execution and sleeping for too short may lead to an intermittently failing test. Instead, leverage "wait-for" commands, which still pause but ensure the tests continue as soon as the desired state is reached, making the tests more reliable and performant.
๐ก When is it necessary to wait?
Before Locating the Element:
Ensure that the page or relevant components have fully loaded. Use explicit waits to wait for certain conditions, like the visibility of an element or the presence of an element in the DOM.CSS Selector and Element Existence:
Ensure that the CSS selector is correct and that the targeted element actually exists in the DOM at the time of the search. It's possible the element is dynamically loaded or changed due to a recent update in the application.Context Switching:
Consider switching the context to that iframe or modal before attempting to locate the element.Recent Changes:
If the issue started occurring recently, review any changes made to the application that could affect the visibility or availability of the element.Timeout Period:
If the default timeout is too short for the page or element to load, consider increasing the timeout period. This is especially useful for pages that take longer to load due to network latency or heavy JavaScript use.
Guidelines
โ Explicit wait: wait until a condition occurs.
await driver.wait(async () => {
info = await getBackupJson();
return info !== null;
}, 10000);โ Explicit wait: wait until a condition occurs.
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);โ Explicit wait for element to load
await driver.waitForSelector('.import-srp__actions');โ Explicit wait for element no present especially when navigating pages
await driver.waitForElementNotPresent('.loading-overlay__spinner');โ SetTimeout: the hard-coded wait may be longer than needed, resulting in slower test execution.
await driver.delay(timeInMillis);โ SetTimeout: The hard-coded wait may be longer than needed, resulting in slower test execution.
await new Promise((resolve) => setTimeout(resolve, timeInMillis));Repeatable tests
Avoiding conditional statements in the end-to-end tests promotes simpler and more maintainable tests. A clear linear flow helps with understanding especially when it comes to identifying and fixing problems in failed tests. Changes in the extension's behaviour can easily break tests that rely on specific conditions to be met. Itโs important to write repeatable tests that produce the same result every time they are run regardless of the environment. Conditional statements add variability to the tests as well as increase the test's complexity, making it harder to control the outcome. Changes to the test will need to be made in multiple places as the test logic is spread across branches.
Guidelines
โ Avoid following statements as they introduce complexity and variability into the tests:
- If statement
- Ternary operator
- Switch
- Short circuiting
//example code:
if (type !== signatureRequestType.signTypedData)โ Proposed solution: Remove conditional statements in test, structure the tests in a way that avoids branching logic.
Assertions
Assertions should verify expected behaviour and outcomes during test execution. When tests are responsible for assertions rather than being hidden in helper functions, it promotes test readability as expected results are explicitly stated. This makes it easier for engineers to quickly understand the intent of the test as well as the cause of failures.
Guidelines
โ Explicit assertion: clear and concise error message
const isExpectedBoxContentPresentAndVisible =
await driver.isElementPresentAndVisible({
css: '[data-testid="mm-banner-alert-notification-text"]',
text: options.text,
});
assert.equal(
isExpectedBoxContentPresentAndVisible,
true,
'Invalid box text content',
);
// Error message:
// AssertionError [ERR_ASSERTION]: Expected value to be true:
// + actual - expected
// -'Invalid box text content'โ Race condition: it is possible that the completedTx is found before component rendering is complete, and then completedTx.getText() can return values that don't reflect the final state of the data, and don't reflect what the user will actually see.
const completedTx = await driver.findElement(
'[data-testid="transaction-list-item"]',
);
assert.equal(await completedTx.getText(), 'Send TST');
// Error message:
// AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
// + actual - expected
// + 'Send Token'
// - 'Send TST'โ Assertion which provide clear and concise error messages makes it easier to diagnose and fix issues in tests:
assert.equal(await qrCode.isDisplayed(), true, โThe QR code should be displayedโ);
// error: The QR code should be displayed + expected - actual
// -false
// + trueโ Assertion with unclear error message:
assert.equal(await qrCode.isDisplayed(), true);
// AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: false !== trueEnhancing test stability with request mocking
Intercepting network requests and substituting responses with predefined mocks significantly improves the speed and stability of the end-to-end tests by eliminating reliance on external services. This approach not only provides greater control over APIs, allowing a wide range of scenarios including network errors to be tested, but also helps to verify the extension's behaviour under adverse network conditions.
It's important to note that third-party websites and applications, which are beyond the control of the MetaMask engineering team, may not always behave consistently or be available. Relying on these external services in tests increases the chance of failures due to unknown factors such as content changes, service issues, or unstable network connections. Therefore, you should aim to minimise such dependencies and be prepared for intermittent access issues.
Guidelines for intercepting network requests
Control the network by intercepting requests or waiting for requests to be responded to.
- This could be a scam
// File: test/e2e/tests/security-provider.spec.js
return {
statusCode: 200,
json: {
flagAsDangerous: 1,
},
};- Request may not be safe: change the response body to mock a different scenario
// File: test/e2e/tests/security-provider.spec.js
return {
statusCode: 200,
json: {
flagAsDangerous: 2,
},
};- Request not verified: change the response code to simulate different response classes
// File: test/e2e/tests/security-provider.spec.js
return {
statusCode: 500,
json: {},
};- Waits for a request to be responded to.
// File: test/e2e/tests/metrics.spec.js
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 10000);Guidelines for limiting third-party calls
Reduce reliance on external services and using techniques like response mocking and local simulationsย in order to control unpredictability and enhance test reliability.
โ Full control over the dapp by using versions. No internet connection required.
Type: E2E Test Dapp
Url: http://localhost:8080โ ๏ธ Full control over changes or updates to the dapp by using versions. Requires an internet connection
Type: Test Snaps
Url: https://metamask.github.io/test-snaps/5.1.0/โ ๏ธ Partial control over changes or updates to the dapp. Requires an internet connection.
Type: E2E Test Dapp
Url: https://metamask.github.io/test-dapp/โ No control over changes or updates to the dapp. Requires an internet connection.
Type: Dapp
Url: https://app.uniswap.org/#/swapโ No control for external dependency.
File: test/e2e/tests/import-flow.spec.js
Test Name: Connects to a Hardware wallet for Trezorโ Proposed solution: The Trezor import flow involves opening the Trezor website, then the user takes additional steps on that website to connect the device. A fake version of this website could be created for testing, and the test build could be updated to use this fake version. It's also worth investigating a phishing detection solution, such as replacing Github.com with an empty page.
Page Object Model (POM)
POM, or Page Object Model, is a design pattern commonly utilized for writing automated end-to-end tests. A page object is an instance of a class that acts as an interface for the page of the application under test.
This design pattern is being adopted due to its numerous benefits, including improved test maintenance, increased code reusability, and enhanced readability of test scripts. By abstracting the UI details into page objects, changes to the application's UI require updates only in the page objects, significantly reducing the effort needed to maintain tests as the application evolves. The adoption of POM in our e2e testing strategy aims to leverage these advantages to create a more robust, maintainable, and efficient testing framework.
Composition of a Page Class
Each page class is composed of:
- Locators: HTML elements.
- Action Methods: Methods for interacting with the elements.
- Check_ Methods: Methods that assert the status of elements.
A test creates page objects and interacts with web elements by calling methods of those page objects.
Best Practices
- A page object should represent meaningful elements of a page and not necessarily a complete page. It can represent a component of a page, like a navbar. See example here.
- All locators should be kept in the page object file. Carefully define locators for elements in page objects, opting for robust locators since they will be extensively used across various locations. For guidance on crafting these resilient locators, refer to the element locators section, which outlines the recommended strategies for their creation and usage.
- Page objects should remain independent and not invoke other page objects to prevent circular references, ensuring they are typically isolated from each other. For handling complex workflows that require interaction across multiple pages, flows should be implemented. Check out the implementation here. This approach enables the incorporation of all relevant page objects to support specific flows, such as login, sending a transaction, or creating a swap. A dedicated
flowsfolder is used to organize and manage these complex workflows. - The tests should only call page object methods or flows, they shouldn't interact directly with page elements. See example here.
- Page object methods should include detailed logs and detailed error messages in all
check_methods to aid in debugging tests. - Place assertions inside of
check_methods, and callcheck_methods inside of tests instead of making assertions directly. Along with enhanced logging, this ensures thatcheck_methods are reusable across different tests. See example here. - Page objects and tests should be written in TypeScript.
- Follow the naming conventions outlined below for page objects, locators, and methods.
Naming Convention
Page Objects
- Classes start with a capital letter following the word โpageโ, e.g.,
LoginPage. - Page file names are a kebab-case version of the class name, e.g.
LoginPagewould be contained in a file calledlogin-page.ts. - Flow file names should follow the "flow.ts" suffix and be in kebab-case, e.g., a file defining login flow should be named
login.flow.ts.
Locators
- Locators should be suffixed by the type of the element they represent, e.g.,
submitButton,passwordInput, etc.
Action Methods
- Follow the camelCase standard, with names that clearly indicate an action and are self-explanatory, e.g.,
confirmTx().
Check\_ Methods
- Using
check_followed by camelCase for all check methods effectively distinguishes them from action methods, e.g.,check_expectedBalanceIsDisplayed(). These methods perform the same function asassert()statements in the current test body. This naming convention ensures that check methods are as prominent asassert()statements in the existing code. This approach proves especially advantageous for long test bodies.
File: docs/testing/e2e/mobile-e2e-guidelines.md
MetaMask Mobile E2E Test Guidelines
Crafting Tests Like a Masterpiece
In the realm of software testing, writing test code is akin to composing a compelling narrative. Just as a well-constructed story draws readers in with clarity and coherence, high-quality test code should engage developers with its readability and clarity. To ensure that our tests convey their purpose effectively, let's delve into some foundational principles:
๐บ Locator Strategy
The default strategy for locating elements is using Test IDs. However, in complex scenarios, employ Text or Label-based locators.
Locating Elements byID
To add Test IDs to a component, consult Detox's guidelines.
Store Test IDs in separate '{PAGE}.selectors.js files, corresponding to each page object. For example, the page object PrivacyAndSecurity should have a testIDs file called PrivacyAndSecurity.selectors.js
Locating Elements byLabel
In some instances on Android, elements are not located by TestIDs. The alternative is to locate elements byLabel.
โ
Good: Utilize the byLabel() element locator on Android when Test IDs aren't detected correctly.
device.getPlatform() === 'android'
? Matchers.getElementByLabel('ELEMENT-ID')
: Matchers.getElementByID('ELEMENT-ID');โ Bad: Attempting to create android specific and ios specific locators:
device.getPlatform() === 'android'
? Matchers.getElementByLabel('ANDROID-ELEMENT-ID')
: Matchers.getElementByID('iOS-ELEMENT-ID');Locating Elements byText
โ
Good: When locating elements by text, retrieve the corresponding text string from the en.json file in the locales/languages folder. For instance, if you need to interact with Show Hex Data, access it as follows:
import en from '../../locales/languages/en.json';
en.app_settings.show_hex_data;โ Bad: Hardcoding text strings without retrieving them from the language file, like this:
const elementText = 'Show Hex Data'; // Hardcoded text๐ Naming Convention
Descriptive and clear names are the cornerstone of maintainable code. Vague, cryptic names leave readers puzzled.
Variables/Methods
โ Good: Name variables and methods with utmost clarity.
tapCreateWalletButton() {
}โ Bad: Using abbreviated or unclear names for variables and methods, like this:
tapNTB() {
}or
tapBtn() {
}Test IDs
When crafting Selector objects, it is crucial to name the object in a way that identifies the specific page where the elements are. The Selector object can be of type ID or string, and it is essential to explicitly specify this distinction. For instance, when creating selectors for the AddCustomToken view, planning to utilize both testIDs and strings as locator strategies, two separate Selector objects should be created.
When naming selector objects follow this pattern:{ScreenName}Selectors{Type}. In this case {TYPE} means the selector type, i.e. IDs or Strings.
โ Good:
export const AddCustomTokenViewSelectorsIDs = {};
export const AddCustomTokenViewSelectorsText = {};โ Bad: Avoid generic names.
export const customTokens = {};For selector keys, use uppercase letters to define them. Follow the format ELEMENT_NAME.
For instance:
โ Good:
export const AddCustomTokenViewSelectorsIDs = {
TOKEN_SYMBOL: '',
};โ Bad:
export const AddCustomTokenViewSelectorsIDs = {
tokenSymbol: '',
};For selector values, craft specific, lowercase strings using the format {screen}-{elementname}.
For example:
โ Good:
export const AddCustomTokenViewSelectorsIDs = {
TOKEN_SYMBOL: 'token-screen-symbol',
};โ Bad: Avoid employing generic, ambiguous strings
export const AddCustomTokenViewSelectorsIDs = {
TOKEN_SYMBOL: 'symbol',
};๐จ A crucial point to note is that if you are creating selector string objects, it is recommended to utilize strings from a localization file.
โ Good:
import locales from '../../locales/languages/en.json';
export const AddCustomTokenViewSelectorsText = {
IMPORT_BUTTON: locales.add_asset.tokens.add_token,
};โ Bad:
export const AddCustomTokenViewSelectorsText = {
IMPORT_BUTTON: 'add-token',
};In summary: Create Selector objects for locating elements. If your strategy involves locating by ID or by text, ensure that the Selector object name reflects that strategy. The object should contain key-value pairs, where keys represent different UI elements and values are unique identifiers.
export const AddCustomTokenViewSelectorsIDs = {
CANCEL_BUTTON: 'add-custom-asset-cancel-button',
CONFIRM_BUTTON: 'add-custom-asset-confirm-button',
};Or in terms of creating a selector string object:
export const AddCustomTokenViewSelectorsText = {
TOKEN_SYMBOL: messages.token.token_symbol,
IMPORT_BUTTON: messages.add_asset.tokens.add_token,
};โ Bad:
const DELETE_WALLET_INPUT_BOX_ID = 'delete-wallet-input-box';Assertions
To ensure consistency and avoid redundancy in our test scripts, we've implemented an Assertion class. Whenever assertions are needed within tests, it's preferred to utilize methods provided by this class.
โ Good: Utilize assertion methods from the Assertion class to perform assertions in tests. For instance:
await Assertions.checkIfToggleIsOn(SecurityAndPrivacy.metaMetricsToggle);โ Bad: Refrain from using built-in assertion methods directly within tests, as shown below:
await expect(element(by.id(Login - button))).toHaveText(login - text);NOTE: Generally speaking, you donโt need to put an assertion before a test action. You can minimize the amount of assertions by using a test action as an implied assertion on the same element.
๐ Method Design
โ Good: Embrace the "Don't Repeat Yourself" (DRY) principle. Reuse existing page-object actions to prevent code duplication and maintain code integrity. Ensure each method has a singular purpose.
โ Bad: Cluttering methods with multiple tasks and duplicating code, like this:
tapNoThanksButton() {
await OnboardingWizard.isVisible();
await TestHelpers.waitAndTap(NO_THANKS_BUTTON_ID);
await TestHelpers.isNoThanksButtonNotVisible();
}๐ Comments
โ Good: Use JSDoc syntax for adding documentation to clarify complex logic or provide context. Excessive comments can be counterproductive and may signal a need for code simplification.
/**
* Get element by web ID.
*
* @param {string} webID - The web ID of the element to locate
* @return {Promise} Resolves to the located element
*/
async getElementByWebID(webID) {
return web.element(by.web.id(webID));
}โ Bad: Over Commenting simple and self-explanatory code, like this:
// This function taps the button
tapButton() {
// ...Creating Page Objects:
Page objects serve as the building blocks of our test suites, providing a clear and organized representation of the elements and interactions within our application.
When creating page objects, follow these principles to ensure clarity, maintainability, and reusability.
๐ Consistency in Structure
Establishing a well-defined structure for your page objects is crucial as it facilitates better understanding and code navigation for fellow engineers.
โ Good: Define a clear structure for your page objects, organizing elements and actions in a logical manner. This makes it easy to reuse elements and actions across multiple tests. For example:
Page object
class SettingsPage {
// Element selectors
get networksButton() {
/*...*/
}
// Actions
async tapNetworksButton() {
/*...*/
}
}Spec file:
it('tap networks button', async () => {
await SettingsView.tapNetworks();
});โ Bad: Tests Do Not Adhere to Page Objects. When tests do not adhere to page objects, it leads to code that is difficult to maintain, prone to duplication, and lacks clarity in its structure.
it('create a new wallet', async () => {
// Check that Start Exploring CTA is visible & tap it
await TestHelpers.waitAndTap('start-exploring-button');
// Check that we are on the metametrics optIn screen
await TestHelpers.checkIfVisible('metaMetrics-OptIn');
});๐ท๏ธ Meaningful Naming
Good:
โ Choose descriptive names for page object properties and methods that accurately convey their purpose. For example:
class SettingsPage {}โ Bad: Unclear or ambiguous names that make it difficult to understand the purpose of the page object:
class Screen2 {}๐ฆ Encapsulation of Interactions
By encapsulating interaction logic within methods, test code does not need to concern itself with the specifics of how interactions are performed. This allows for easier modification of interaction behavior without impacting test scripts.
โ Good: Encapsulate interactions with page elements within methods, abstracting away implementation details. For example:
class SettingsPage {
async tapNetworksButton() {
/*...*/
}
}โ Bad: Exposing implementation details and directly interacting with elements in test code:
// Test code
await TestHelpers.waitAndTap('start-exploring-button');โ๏ธ Utilization of Utility Functions
Using utility functions is a reliable way to interact with page elements consistently across various tests. By centralizing the interaction logic and removing low-level details, utility functions improve readability and enhance the scalability of tests.
โ Good: Leverage utility functions to interact with page elements consistently across tests:
import Matchers from '../../utils/Matchers';
import Gestures from '../../utils/Gestures';
class SettingsPage {
get networksButton() {
return Matchers.getElementByID('ELEMENT-STRING');
}
async tapNetworksButton() {
await Gestures.waitAndTap(this.networksButton);
}
}NOTE: Matchers and Gestures are fundamental components of our test actions.
Matchers Utility Class: Handles element identification and matching logic, ensuring consistency in element location across tests. This promotes consistency and reduces code duplication.
Gestures Utility Class: Manages user interactions with page elements, such as tapping, swiping, or scrolling. It enhances test readability by providing descriptive methods for common user interactions.
โ Bad: Implementing interactions directly in test code without using utility functions. For example:
await element(by.id('ELEMENT-STRING')).tap();๐๏ธ Using Getters for Element Storage
In our page objects, we utilize getters for element storage instead of defining selectors within the constructor. This deliberate choice stems from the behavior of getters, which are evaluated when you access the property, not when you create the object.
โ Good: By encapsulating element selectors within getters, we ensure that elements are requested immediately before any action is taken on them. This approach promotes a more dynamic and responsive interaction with the page elements, enhancing the reliability and robustness of our tests.
For example:
import { NetworksViewSelectorsIDs } from '../../selectors/Settings/NetworksView.selectors';
class SettingsPage {
get networksButton() {
return Matchers.getElementByID(SettingsViewSelectorsIDs.NETWORKS);
}
async tapNetworksButton() {
await Gestures.waitAndTap(this.networksButton);
}
}โ Bad: Defining element selectors as constants and then interacting with them within test actions can lead to potential issues with element staleness and unexpected behavior.
const CONFIRM_BUTTON_ID = 'contract-name-confirm-button';
export default class ContractNickNameView {
static async tapConfirmButton() {
await TestHelpers.waitAndTap(CONFIRM_BUTTON_ID);
}
}A Comprehensive Guide on Implementing Page Objects for Test Scenarios
This guide provides a comprehensive overview of implementing the concepts discussed earlier to create a page object for use in a test scenario. By following this guide, you will gain a deeper insight into our approach to writing tests, enabling you to produce test code that is both maintainable and reusable.
Step 1: Define Selector Objects for NetworksView
Start by creating selector objects. These objects store identifiers for UI elements, making your tests easier to read and maintain. We'll use two types of selectors: IDs and Text. Let's call this file: NetworksView.selectors.js
import enContent from '../../../locales/languages/en.json';
export const NetworksViewSelectorsIDs = {
RPC_CONTAINER: 'new-rpc-screen',
ADD_NETWORKS_BUTTON: 'add-network-button',
NETWORK_NAME_INPUT: 'input-network-name',
BLOCK_EXPLORER_INPUT: 'block-explorer',
RPC_URL_INPUT: 'input-rpc-url',
CHAIN_INPUT: 'input-chain-id',
NETWORKS_SYMBOL_INPUT: 'input-network-symbol',
RPC_WARNING_BANNER: 'rpc-url-warning',
NETWORK_CONTAINER: 'networks-screen',
CUSTOM_NETWORK_LIST: 'custom-networks-list',
};
export const NetworkViewSelectorsText = {
BLOCK_EXPLORER: enContent.app_settings.network_block_explorer_label,
REMOVE_NETWORK: enContent.app_settings.remove_network,
CUSTOM_NETWORK_TAB: enContent.app_settings.custom_network_name,
POPULAR_NETWORK_TAB: enContent.app_settings.popular,
};Step 2: Create the Networks Page Object
The Networks Page Object encapsulates interactions with the NetworksView. It uses the selectors defined in Step 1 and utility functions for actions like typing and tapping. Methods within the page object hide the complexity of direct UI interactions, offering a simpler interface for test scripts. Use Matchers for finding elements and Gestures for performing actions, enhancing code reusability.
/* Detailed source-code truncated for AI context efficiency. */Step 3: Utilize Page Objects in Test Specifications
With the page object in place, writing test specifications becomes more straightforward. The test scripts interact with the application through the page object's interface, improving readability and maintainability.
Example Test Case: Adding a Network
import NetworkView from '../../pages/Settings/NetworksView';
import Assertions from '../../utils/Assertions';
import Networks from '../../resources/networks.json';
it('add Gnosis network', async () => {
// Tap on Add Network button
await NetworkView.tapAddNetworkButton();
await NetworkView.switchToCustomNetworks();
await NetworkView.typeInNetworkName(Networks.Gnosis.providerConfig.nickname);
await NetworkView.typeInRpcUrl('abc'); // Negative test. Input incorrect RPC URL
await Assertions.checkIfVisible(NetworkView.rpcWarningBanner);
await NetworkView.clearRpcInputBox();
await NetworkView.typeInRpcUrl(Networks.Gnosis.providerConfig.rpcUrl);
await NetworkView.typeInChainId(Networks.Gnosis.providerConfig.chainId);
await NetworkView.typeInNetworkSymbol(Networks.Gnosis.providerConfig.ticker);
await NetworkView.tapRpcNetworkAddButton();
});File: docs/testing/e2e/mobile-e2e-mocking.md
Mocking APIs in MetaMask Mobile for Enhanced E2E Testing
Introduction
This document outlines how MetaMask Mobile uses API mocking to boost End-to-End (E2E) testing. Mocking lets us simulate different conditions that the app might face, ensuring it functions reliably across various scenarios.
Mocking vs. E2E Testing
While E2E tests verify the overall user experience, mocking focuses on testing individual components. Hereโs how they differ:
- E2E Tests: These validate the app's functionality as a whole, interacting with real APIs and backend services.
- Mocking: Simulates responses from APIs, allowing us to test components in specific network conditions or edge cases.
We use mocking to enhance our E2E testing, especially for scenarios where E2E alone might be unreliable or tricky to set up.
File Structure
We keep E2E and mock tests separate with file naming conventions:
root/
โโโ e2e/
โ โโโ spec/
โ โ โโโ Accounts/
โ โ โ โโโ spec.js
mock.spec.js# Mock test for Accounts
โ โ โโโ Transactions/
โ โ โ โโโ spec.js
mock.spec.js # Mock test for Transactions
โ โโโ api-mocking/
โ โโโ api-mocking/
โ โ โโโ mock-responses/
โ โ โ โโโ gas-api-responses.jsonThis structure promotes clear organisation and makes managing tests simpler.
Mock Server Implementation
Overview
We use Mockttp to simulate API responses, providing flexible testing across different HTTP methods. This allows us to test app behaviour even when external dependencies are unavailable or unreliable.
Key Features
- Supports multiple HTTP methods (GET, POST, etc.)
- Configurable requests and responses
- Logs responses to simplify debugging
Naming Mock Test Files
Mock test files are named with .mock.spec.js to keep things organised. For example, a test for the suggested gas API would be named: suggestedGasApi.mock.spec.js.
Setting Up the Mock Server
The startMockServer function in e2e/api-mocking/mock-server.js starts the mock server. It takes events organised by HTTP methods, specifying the endpoint, response data, and request body (for POST requests).
import { mockEvents } from '../api-mocking/mock-config/mock-events';
mockServer = await startMockServer({
GET: [mockEvents.GET.suggestedGasApiErrorResponse],
POST: [mockEvents.POST.suggestedGasApiPostResponse],
});Defining Mock Events
mockEvents.js defines mock events, including:
urlEndpoint: The API endpoint being mockedresponse: The mock response the server will returnrequestBody: Expected request body (for POST requests)
export const mockEvents = {
GET: {
suggestedGasApiErrorResponse: {
urlEndpoint: 'https://gas.api.cx.metamask.io/networks/1/suggestedGasFees',
response: { status: 500, message: 'Internal Server Error' },
},
},
POST: {
suggestedGasApiPostResponse: {
urlEndpoint: 'https://gas.api.cx.metamask.io/networks/1/suggestedGasFees',
response: { status: 200, message: 'Success' },
requestBody: { priorityFee: '2', maxFee: '2.000855333' },
},
},
};Response Structure
Mock responses are stored in individual JSON files for each API or service within the mock-responses folder, making them easier to maintain and manage. Each API service has its own JSON response file, such as gasApiResponse.json for gas-related responses and ethpriceResponse.json for Ethereum price responses. This organisation enables clear separation of mock data and simplifies updates or additions.
Example: gasApiResponse.json
{
"suggestedGasApiResponses": {
"error": {
"message": "Internal Server Error"
}
},
"suggestedGasFeesApiGanache": {
// ... detailed gas fee data ...
}
}Logging
The mock server logs response statuses and bodies to help track mocked requests, making debugging more straightforward.
Using Mock Testing Effectively
When to Use Mocks:
- For testing isolated features without relying on live data
- For testing edge cases that are tricky to reproduce with real data
- For deterministic test results by controlling inputs and outputs
When Not to Use Mocks:
- Stable Live Environments: When APIs and services are reliable, testing live ensures production-like accuracy.
- Integration Testing: Live tests validate interactions with third-party services, capturing real-world behaviour.
- Performance Testing: Only live environments provide accurate latency and throughput metrics.
- Dynamic Data Scenarios: Features relying on user data or complex workflows may reveal issues that mocks miss.
There should be a mix of tests that verify real-life services and some that use mocks, when applicable, to achieve comprehensive coverage.
Utilizing Fixtures with testSpecificMock
For more complex mock events or criteria, you can use the mockSpecificTest object to define custom mock events.
When using fixtures for E2E tests, you can leverage the testSpecificMock object to inject specific mocks into your test cases. This allows you to dynamically modify mock responses based on the scenario under test. For example:
test.use({
testSpecificMock: {
GET: [
{
urlEndpoint:
'https://gas.api.cx.metamask.io/networks/1/suggestedGasFees',
response: { status: 200, message: 'Custom Success Response' },
},
],
},
});This approach is particularly useful for targeted scenarios where pre-defined mock events in mockEvents.js might not be sufficient or applicable.
For example, see this GitHub Reference.
When mockEvents May Not Be Suitable
In certain cases, using mockEvents might not be idealโfor instance, when:
- The mock responses need to be dynamically created based on the test input.
- The scenario being tested is unique and requires a one-off response that doesnโt fit the general structure of
mockEvents. - Reusing existing mocks may lead to inconsistencies in the expected results.
In such cases, testSpecificMock is a more suitable option, allowing you to define bespoke mocks for individual tests.
Current Workaround for Network Request Interception
Due to limitations in intercepting network requests directly, we currently route traffic through a proxy server. This lets us intercept and mock requests as needed.
Future Improvements
Weโre looking into further enhancements for our mocking setup to simplify processes and improve test coverage.
File: docs/testing/e2e-testing.md
MetaMask E2E Test Guidelines
General
The primary goal of writing end-to-end (E2E) tests at MetaMask is to ensure that all of the user flows within the product function as designed. Therefore, test coverage is a crucial metric for evaluating and improving quality. The higher the coverage is, the more confidence this creates among engineers in the product, and the more effectively bugs can be identified and eliminated.
The process of creating an E2E test involves several steps: identification, development, testing, and maintenance. Initially, it is important to identify the right aspects of the product to test. The next step is to develop the tests, adhering to the quality guidelines outlined in this document. This includes creating clear test cases, using appropriate assertions, and structuring the tests for easy maintenance. Finally, it's crucial to ensure that tests are robust and reliable. This means the test should consistently produce the same results and be resilient to minor system changes. By following these steps, effective E2E tests can be created that contribute to delivering a high-quality product.
This document serves as a guideline to approach writing E2E tests so that they provide fast feedback, they become less fragile over time, they are painless to debug, and they are scalable and easy to maintain. The remainder of this document will attempt to explain the suggested approach with relevant examples to help better understand the future direction for tests.
These guidelines aren't meant to be a strict set of rules, they should be thought of as an approach to writing robust end-to-end tests. Use them where they make sense and adapt them as necessary to complement your tests.
We have implemented two separate approaches for structuring tests on extensions and mobile. This document serves as a valuable reference point for establishing a common ground when writing end-to-end (E2E) tests across both clients. To gain a more comprehensive understanding of the specific guidelines for test writing on each platform, you can find detailed information here:
Below, we present our standardized approach to writing tests that applies universally to both clients
Test names
The test name should communicate the purpose and behaviour of the test. A clear test name serves as a concise summary of what the test aims to verify, making it easier for everyone to understand and maintain the tests. Clear test names improve the readability of the test and help when it comes to debugging a failed test. You should be able to figure out the purpose of the test without going through the complete implementation. If the test is failing, it should be easy to figure out which functionality is broken from the test name.
Guidelines
โ Recommended readable test names
- adds Bob to the address book
- send 1 TST to Bobโ ๏ธ Test names remain approachable, recommend to avoid using the meaningless prefix 'should'
- should add Bob to the address book
- should send 1 TST to Bobโ Test name should be completely avoided: The use of a should prefix and the word and can decrease the readability of the test,making it harder to understand what the test is doing as well as diagnose and fix issues.
- should add Bob to the address book and send 1 TST to Bobโ Recommended readable test names
- removes an account imported with a private key
- impossible to remove an account generated from the SRP imported in onboarding
- the UI environment is locked down
- the background environment is lockedโ Test name should be completely avoided
// in file: e2e/tests/add-account.spec.js
- should be possible to remove an account imported with a private key, but should not be possible to remove an account generated from the SRP imported in onboarding
// in file: e2e/tests/lockdown.spec.js
- the UI and background environments are locked downOrganization of test files
It's essential to keep test files organised. As test suites get bigger, a well-structured organisation makes it easier to search for tests as well as identify logical groups of tests that may be impacted by changes to an area of the application. When tests are organised based on related features or functionality it becomes easier to identify common helper functions that can be shared across the tests, reducing duplication.
Organise tests into folders based on scenarios and features. This means that each type of scenario has its own folder, and each feature team owns one or more folders.
- This approach assigns ownership of E2E testing at the feature team level. Each feature team, being aware of the features they own, finds it easier to understand the existing tests and identify what's missing.
- The future goal is for specific feature teams to manage the necessary test cases for their own features, instead of the core teams tracking each one.
- Using this organizational strategy eliminates the need to decide at a low level where to place a test, as it is straightforward based on the feature or scenario.
- The extension and mobile team both adhere to the same method for organizing test files. This consistency reduces friction when switching context.
Example for organization of test files by features and scenarios:
// current test path:
e2e/tests/nft/import-erc1155.spec.js
// recommended test path: (consolidate all import tests for different tokens into a single repository)
e2e/tests/tokens/import/import-erc1155.spec.js
// current test path:
e2e/tests/clear-activity.spec.js
// recommended test path:
e2e/tests/settings/clear-activity.spec.js
// current test path:
e2e/tests/ppom-blockaid-alert-erc20-approval.spec.js
// recommended test path:
e2e/tests/ppom/ppom-blockaid-alert-erc20-approval.spec.jsTest Atomicity and Smart Test Coupling
Guidelines
Maintaining test atomicity and smart test coupling is crucial for effective end-to-end testing. Test atomicity ensures that each test is self-contained and independent, avoiding dependencies between tests. This is important because test coupling, where the outcome of one test influences the execution or success of another, can complicate the identification of failure causes.
To ensure test isolation and simplify parallelisation, each test is run with a dedicated browser and dedicated mock services (e.g. fixtures, metrics, etc.). The withFixtures function helper can be used to create everything the test needs and to perform all cleanup steps after each test, ensuring that they are completely isolated from each other. It is best to avoid shared mocks and services between tests.
On the other hand, allowing a degree of test coupling in E2E tests can enhance the effectiveness of testing. This approach is useful to construct tests that combine multiple features, providing a closer approximation to real user behaviour. Unlike in E2E tests, real users do not commence each task with a 'clean' setup. Combining several atomic tests into a single user-flow based test may increase the execution time for individual tests. However, it eliminates the need for repeated setup, application installation, and opening of the application. As a result, the total test execution time is likely to decrease.
However, it's important to strike a balance. Over-reliance on long, combined tests can lead to slower, more complex tests that are harder to maintain. Also, if one step fails, the entire test fails, which can make it harder to pinpoint the issue. The key is to understand when and how tests should be isolated from each other, and when they can be strategically combined. Whether you choose to isolate or combine tests will depend on what you're trying to achieve with your tests.
Here are some guidelines to decide when to isolate or combine tests:
- Isolate tests for specific functionalities: If you're testing the behaviour of a single component or feature, it's best to isolate it from other parts of the application. This approach ensures that any test success or failure is due to the component under test, eliminating external factors.
- Combine tests for multiple user flows or cross-functional tests: When testing how different parts of the application work together to perform a more complicated user journey, combining tests can be beneficial. This approach allows you to verify a suite of user flows in a cohesive manner.
- Adopt a fail-fast philosophy: If an initial step in a test sequence fails, it may not be logical or efficient to proceed with subsequent steps. In such cases, adopting test coupling can be beneficial. However, consider the impact of a failure. If a failure in one part of a combined test makes it impossible to test the rest, but you still need to test the subsequent parts regardless of the outcome of the first part, it might be better to isolate the tests.
Remember, the goal is to create tests that are reliable, easy to understand, and provide valuable feedback about the system. Whether you choose to isolate or combine tests will depend on what you're trying to achieve within your tests. Ideally, you should aim for a large number of unit tests to test individual code pieces and a medium number of E2E tests for user flow testing that cover all the scenarios.
Controlling state
To achieve test atomicity and ensure E2E tests are stable and reliable, it is best to control the state of the application programmatically rather than relying on the application UI. Doing so eliminates unnecessary UI interactions, decreasing the amount of possible breaking points in a test. It also improves test stability by minimising issues caused by timing synchronisation or inconsistencies in the UI, reducing the test execution time and allowing the test to provide fast and focused feedback.
Guidelines
โ Use fixture to remove multiple redundant steps: Add Contact, Open test dapp, Connect to test dapp, Deploy TST, Add TST to wallet
new FixtureBuilder()
.withAddressBookControllerContactBob()
.withTokensControllerERC20()
.build();Send TST test steps after setting this fixture:
Login
Send TST
Assertionโ ๏ธ Use fixture to remove single redundant steps: Add Contact
new FixtureBuilder().withAddressBookControllerContactBob().build();Send TST test steps after setting this fixture:
Login
Open test dapp
Connect to test dapp
Deploy TST
Add TST to wallet
Send TST
Assertionโ Use the UI to build state
new FixtureBuilder().build();Send TST test steps after setting this fixture:
Login
Add Contact
Open test dapp
Connect to test dapp
Deploy TST
Add TST to wallet
Send TST
AssertionHere are some examples to remove redundant steps following above guidelines:
// test file: test/e2e/tests/multiple-transactions.spec.js
scenario: creates multiple queued transactions and then confirms.
solution: Ensure tests are different from the tests that line in test/e2e/tests/navigate-transactions.spec.js// test file: test/e2e/tests/multiple-transactions.spec.js
scenario: creates multiple queued transactions, then rejects.
solution: create multiple transactions using the fixture builder rather than connecting to the test dapp and creating transactions through the UI// test file: test/e2e/tests/add-account.spec.js
scenario: should not affect public address when using secret recovery phrase to recover account with non-zero balance
solution: replace UI steps that build up the application state with the FixtureBuilder// test file: test/e2e/tests/import-flow.spec.js
scenario: import Account using private key and remove imported account
solution: replace UI steps that build up the application state with the FixtureBuilderFeature flags in E2E tests
MetaMask uses two categories of feature flags, and each requires a different approach in E2E tests. Understanding the distinction helps you write tests that accurately reflect production behavior.
Remote feature flags (runtime)
Remote feature flags are fetched at runtime from a configuration service. In production, the application calls a remote API to retrieve flag values. During E2E tests, a mock server or fixture intercepts these requests and returns controlled values instead.
Each client should maintain a feature flag registry โ a central source of truth that maps every remote flag to its current production default value. The E2E mock layer reads from this registry so that tests run against production-accurate defaults by default, without calling the real API.
Guidelines
- Default to production values. Tests should use the same flag values that real users see unless the test is specifically verifying behavior behind a different flag state. The registry makes this automatic.
- Override only when needed. When a test must exercise a non-default flag state, use the test framework's override mechanism (e.g. fixture builder methods or manifest flag overrides) rather than changing the registry itself.
- Register every flag. Any remote flag referenced in application code should have a corresponding entry in the registry. CI checks can enforce this automatically by scanning for flag references and verifying they exist in the registry.
- Keep the registry up to date. When a flag is fully rolled out or removed from the remote API, update or remove its registry entry. Stale entries lead to tests that no longer reflect production.
- No custom builds required. Because remote flags are resolved at runtime, you do not need to create a special build to test different remote flag values. Override them at the test level.
Examples
โ Registry entry with production default โ tests automatically use this value:
redesignedConfirmations: {
name: 'redesignedConfirmations',
type: FeatureFlagType.Remote,
productionDefault: true,
status: FeatureFlagStatus.Active,
},โ Override a remote flag for a specific test without changing the registry:
// Override via fixture builder
new FixtureBuilder()
.withRemoteFeatureFlags({ redesignedConfirmations: false })
.build();// Override via manifest flags in test options
await withFixtures(
{
manifestFlags: {
remoteFeatureFlags: { redesignedConfirmations: false },
},
},
async () => {
// test runs with the flag disabled
},
);โ Modifying the registry to change a flag value for a single test:
// DON'T do this โ it changes the default for all tests
redesignedConfirmations: {
name: 'redesignedConfirmations',
productionDefault: false, // changed from true just for one test
},Build-time feature flags (compile-time)
Build-time feature flags are set during the build process and baked into the compiled output. They control which code paths are included in a given build. Changing a build-time flag requires creating a new build before running tests.
Guidelines
- Create a dedicated test build. To test with a build-time flag enabled, set the flag in the build configuration or pass it as an environment variable, then create a test build.
- Keep build-time flags separate from remote flags. Do not conflate the two. A build-time flag controls what code ships; a remote flag controls runtime behavior of code that is already shipped.
- Document available flags. Each client should document its build-time flags and how to enable them for test builds, so contributors know which flags exist and how to use them.
Examples
โ Enable a build-time flag via environment variable, then run tests:
# Create a test build with the flag enabled
MULTICHAIN=1 yarn build:test
# Run E2E tests against that build
yarn test:e2eโ Enable a build-time flag via local configuration file:
# In your local config file (e.g. .metamaskrc, .env, etc.)
MULTICHAIN=1
# Then build and test as usual
yarn build:test
yarn test:e2eโ Trying to override a build-time flag at the test level (this has no effect):
// DON'T do this โ build-time flags are already compiled in
new FixtureBuilder()
.withBuildFlag({ MULTICHAIN: true }) // has no effect at runtime
.build();General principles
- Test both states when possible. For any flag that gates significant user-facing behavior, consider having tests for both the enabled and disabled states to prevent regressions in either path.
- Avoid flag-dependent test logic in shared helpers. If a helper function behaves differently based on a flag, make the flag value an explicit parameter rather than reading it implicitly. This keeps tests predictable and easy to reason about.
- Clean up after rollout. Once a feature flag is no longer needed (the feature is fully launched or removed), delete the flag references from application code, tests, and the registry. Leftover flags add confusion and maintenance burden.
Examples
โ Testing both flag states explicitly:
describe('token approvals', () => {
it('shows redesigned confirmation when flag is enabled', async () => {
new FixtureBuilder()
.withRemoteFeatureFlags({ redesignedConfirmations: true })
.build();
// assert redesigned UI is shown
});
it('shows legacy confirmation when flag is disabled', async () => {
new FixtureBuilder()
.withRemoteFeatureFlags({ redesignedConfirmations: false })
.build();
// assert legacy UI is shown
});
});โ Making flag dependency explicit in a helper:
function confirmTransaction(driver, { isRedesigned }) {
if (isRedesigned) {
return driver.clickElement('[data-testid="confirm-redesigned"]');
}
return driver.clickElement('[data-testid="confirm-legacy"]');
}โ Reading the flag implicitly inside a shared helper:
// DON'T do this โ the helper's behavior depends on hidden global state
function confirmTransaction(driver) {
const flags = getRemoteFeatureFlags();
if (flags.redesignedConfirmations) {
return driver.clickElement('[data-testid="confirm-redesigned"]');
}
return driver.clickElement('[data-testid="confirm-legacy"]');
}File: docs/testing/overview.md
Testing Overview
At MetaMask, we are committed to delivering high-quality software. These guidelines describe how we use automated tests to maximize product quality while minimizing the cost of writing and maintaining tests.
Our automated tests fall into three general categories: end-to-end (e2e), integration and unit tests. This document provides comprehensive guidelines on each testing type, emphasizing the importance of choosing the right test for the right scenario and detailing the specific practices for writing and organizing these tests.
Testing principles
The following are general principles about how to write automated tests effectively, applicable to all types of automated tests.
1. Tests should describe expectations
What's the difference between a bug and a feature? A feature is expected, a bug is not.
Each test should be verifying specific expectations. If it's not clear what the code-under-test is supposed to do, it won't be clear what to do when the test fails either. Clear expectations are essential for an effective test.
2. Tests should catch bugs
This is the main purpose of tests. Take care to ensure that each test actually fails when the described expectation is not met.
3. Tests should not break often
It's expected that tests will break when we change the behavior or APIs under test. But if a test breaks often from refactors, or from changes to behavior or APIs not directly under test, it's sign that the test (or the code-under-test) may be poorly designed.
Test breakages are expensive. We should do what we can to minimize them.
4. Tests should be easy to read
A test isn't effective if we can't understand why it failed. The more readable a test is, the faster we can fix it.
5. Tests should be easy to write
Some behaviors are inherently more complex than others, increasing the difficulty of testing them. But in general we want tests to be easy to write, so that we can keep costs low.
If a test is hard to write, consider that as a warning that you might be approaching the problem in a non-optimal way. Resist the urge to power through the work, and instead consider how redesigning the code-under-test or using a different testing strategy might save time.
6. Tests should be fast
Making tests faster has a dramatic improvement on productivity, reducing feedback loops.
What we consider "fast" depends on what the test does; what's fast for an end-to-end test might be slow for a unit test. But faster is always better, for all types of tests.
7. Tests should have a high return on investment
If a test doesn't align with the previous 6 principles, it's probably doing more harm than good. Consider deleting it.
That doesn't mean we should skip writing tests when it's too difficult. On the contrary, given our high expectations for quality we don't have the luxury of writing only the easiest tests. We need to aim for total coverage.
However, writing and maintaining bad tests is not the shortest path to complete test coverage. Our time is better spent writing good tests, and making investments that allow us to write more good tests. All code can be designed to be easy to test, and even the most complex test setup/teardown can be reasonably fast and easy to read/write with the right tools.
What are unit tests?
Unit testing is a conceptually vague practice, as there is no consensus on how they should be written and what they should test. But in general, as outlined by Martin Fowler, there are two common approaches: solitary unit tests and sociable unit tests. At MetaMask, we employ both of these types of unit tests (see discussion on the use cases of each type).
Unit tests examine individual components or functions in isolation or within their immediate context. At MetaMask we encourage a flexible approach to unit testing, recognizing the spectrum between sociable and solitary unit tests.
When to write unit tests?
All components or functions should be validated through unit tests. These tests focus on the component's or function's internal logic and functionality.
If UI integration tests are available, those are preferred for page-level components. However, developers may choose to write sociable unit tests for specific scenarios where UI integration tests might not be necessary or practical.
Best Practices: Follow the unit test best practices.
What are UI integration tests?
UI integration tests evaluate the interaction between multiple components within the application, ensuring they work together as intended. For MetaMask, we are aiming to make UI integration tests particularly focused on page-level components and are conducted in the context of the full UI application.
When to write UI integration tests?
UI integration tests should be written for page-level components. These tests should simulate user journeys and validate the application's behavior in a full app context.
Best Practices: Follow the ui integration tests best practices.
What are end-to-end tests?
End-to-end tests simulate real user scenarios from start to finish, testing the application as a whole. In the Extension, end-to-end tests are tests that strive to test a real extension app (including background, contentscript and ui processes) in a controlled environment from a user perspective.
When to write end-to-end tests?
End-to-end tests exercise an application's integration with external systems or services through critical user journeys. This includes any interactions between the UI, background process, dapp, blockchain, etc. End-to-end tests should closely mimic real user actions and flows.
Best Practices: Follow the end-to-end test best practices.
Generic testing considerations
- Code fencing: Code fencing is available in MetaMask Extension and Mobile codebases. Though useful for its purposes, this practice is very problematic for unit/UI integration testing. We should avoid it.
Conclusion
To sum up, understanding the distinction between end-to-end, UI integration and unit tests and applying them appropriately is crucial for maintaining MetaMask's code quality and reliability. UI integration tests offer a broad view of user interactions and system integration for page-level components, while unit tests provide detailed insights into the functionality of individual components, and end-to-end tests validate the application's overall behavior, ensuring readiness for production.
By following these guidelines, developers can ensure comprehensive test coverage, contributing to the robustness and user satisfaction of the application
File: docs/testing/ui-integration-testing.md
UI Integration Testing Guidelines
Use Jest
Just like for unit tests, for UI integration tests, we use Jest as the test runner and testing framework. Jest provides a rich set of features that make it easy to write and maintain tests. UI integration tests should be written following the same conventions as unit tests.
Focus on User Journeys
UI integration tests should focus on user journeys and interactions within the application. Design tests to mimic actual user actions and flows. This helps in identifying issues that could affect the user experience.
Provide Full App Context
Always test page-level components in the context of the full application. This approach ensures that tests reflect real user scenarios and interactions within the app.
UI integration tests should not be written for components other than page-level components. Other components should be tested using unit tests.
Keep Tests Separate from Implementation
Place integration test files within a test/integration directory. This centralized location helps manage tests that span multiple pages and components, improving maintenance and discoverability. This approach differs from unit tests, where co-location with the component is standard.
Avoid Snapshot testing
Refrain from using snapshot testing in UI integration tests. They tend to be less meaningful in the context of full app testing and can lead to brittle tests.
Don't Mock UI Components
Keep mocking to minimum. Ideally only the background connection (MetaMask Extension), or any network request (fired from the UI) should be mocked. Avoid mocking any UI components or hooks. For mocking the background connection we can use jest, for mocking network requests we can use nock (or msw).
File: docs/testing/unit-testing.md
Unit Testing Guidelines
Use Jest
Jest is the testing framework for unit tests at MetaMask. Jest includes many built-in features that make it easier to write tests. Useful features include:
- Module mocks
- Timer mocks
- Snapshots
- Automatic parallelization of tests without restricting usage of exclusive tests
- A consistent emphasis on great developer experience
Colocate test files with implementation files
Place test files next to the code they test. This makes test files easier to find.
๐ซ
src/
permission-controller.ts
test/
permission-controller.tsโ
src/
permission-controller.ts
permission-controller.test.tsUse `describe` to group tests for the same function/method
Wrap tests for the same function or method in a describe block. This provides three benefits:
- Tests are easier to find in large test files
- You can run only these tests using
.only - The test subject (the "it") is clear and focused
๐ซ
describe('KeyringController', () => {
it('adds a new account to the given keyring', () => {
// ...
});
it('removes the account identified by the given address from its associated keyring', () => {
// ...
});
});โ
describe('KeyringController', () => {
describe('addAccount', () => {
it('adds a new account to the given keyring', () => {
// ...
});
});
describe('removeAccount', () => {
it('removes the account identified by the given address from its associated keyring', () => {
// ...
});
});
});๐ก Use `describe` to group tests under scenarios
When multiple tests verify the same conditional behavior, group them in a describe block. This way, you only need to specify the condition once. Start each test's name with if ... or when ... so it forms a complete sentence when combined with the describe block.
1๏ธโฃ
it('delegates to the tokens controller when adding a token', () => {
// ...
});
it('adds the new token to the state when adding a token', () => {
// ...
});
it('returns true when adding a token', () => {
// ...
});2๏ธโฃ
describe('when adding a token', () => {
it('delegates to the tokens controller', () => {
// ...
});
it('adds the new token to the state', () => {
// ...
});
it('returns true', () => {
// ...
});
});Use `it` to specify the desired behavior for the code under test
Each test must focus on a single aspect of the behavior, and its description must also describe that behavior clearly:
- Start the description with an active verb in present tense (e.g., "returns", "displays", "prevents").
- State the expected outcome or behavior directly.
- Add contextual conditions only when necessary for clarity (e.g., "when session expires").
- Keep descriptions concise but complete.
- Avoid filler words ("should", "correctly", "successfully", "gracefully", "properly").
- Avoid vague descriptions ("test", "edge case", "works", "handles").
- Avoid implementation details ("calls redirectTo", "throws InvalidPayloadError").
- Don't use the name of the function as the test description.
Examples
๐ซ Using "should" unnecessarily
it('should successfully add token when address is valid and decimals are set and symbol exists', () => {
// ...
});โ Describe the behavior directly
it('stores valid token in state', () => {
// ...
});๐ซ Listing implementation details and parameters
it('should fail and show error message when invalid address is provided', () => {
// ...
});โ Focus on what is being tested
it('displays invalid address error', () => {
// ...
});๐ซ Stating obvious successful outcomes
it('works correctly when processing the transaction', () => {
// ...
});โ Be specific about the behavior
it('processes transaction', () => {
// ...
});๐ซ Describing implementation instead of behavior
it('calls redirectTo("/login") when session expires', () => {
// ...
});โ Describe the expected outcome
it('redirects to login when session expires', () => {
// ...
});๐ซ Using vague error language
it('throws an error when balance is insufficient', () => {
// ...
});โ Be precise about the expected behavior
it('prevents sending with insufficient balance', () => {
// ...
});Or, when the specific error type is the key behavior:
it('throws InvalidPayloadError on malformed request', () => {
// ...
});๐ซ Missing or unclear description
it('test', () => {
// ...
});
it('edge case', () => {
// ...
});โ Clear, descriptive names
it('returns empty array when input is empty', () => {
// ...
});
it('accepts transaction up to maximum amount limit', () => {
// ...
});Read more
- "Tests as Specification" and "Tests as Documentation" in xUnit Patterns
Keep tests focused
Tests are easier to understand and maintain when they cover only one aspect of behavior.
If you use "and" in a test description, the test is probably too large. Split it into separate tests.
๐ซ
it('starts the block tracker and returns the block number', () => {
// ...
});โ
it('starts the block tracker', () => {
// ...
});
it('returns the block number', () => {
// ...
});Don't directly test private code
Private code is not intended for consumers of an interface, so don't test it directly. Private code includes:
- Functions or classes not exported from a module
- Methods that start with
#(ECMAScript private fields) - Methods that start with
_(older convention before private fields) - Methods with the
privatekeyword in TypeScript - Functions or methods tagged with
@privatein TSDoc
Instead, test the public methods that call the private code, writing tests as if the private code were part of the public method.
The following example defines two private methods:
// block-tracker.ts
import { request } from '...';
export class BlockTracker extends EventEmitter {
isRunning = false;
currentBlock: Block | null = null;
subscriptionId: number | null;
async stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
this.#startClearCurrentBlockTimer();
await this.#end();
}
#startClearCurrentBlockTimer() {
setTimeout(() => {
this.currentBlock = null;
}, 1000);
}
#end() {
await request('eth_unsubscribe', this.subscriptionId);
}
}Since consumers (and tests) can only see the public interface, the example above behaves as if the private methods were inlined:
// block-tracker.ts
import { request } from '...';
export class BlockTracker extends EventEmitter {
isRunning = false;
currentBlock: Block | null = null;
subscriptionId: number | null;
async stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
setTimeout(() => {
this.currentBlock = null;
}, 1000);
await request('eth_unsubscribe', this.subscriptionId);
}
}A test suite for the class might look like this. Testing the public stop method verifies all the behaviors that result from calling it, including the effects of the private methods:
describe('BlockTracker', () => {
describe('stop', () => {
it('does not reset the current block if the block tracker is stopped', () => {
// ...
});
it('does not request to unsubscribe if the block tracker is stopped', () => {
// ...
});
it('resets the current block if the block tracker is running', () => {
// ...
});
it('requests to unsubscribe if the block tracker is running', () => {
// ...
});
});
});Highlight the "exercise" phase
A test has up to four "phases":
- Setup: Configure the environment to run the code under test
- Exercise: Execute the code under test
- Verify: Confirm that the code behaves as expected
- Teardown: Return the environment to a clean state
Use empty lines to separate these phases visually. This helps readers understand the test flow.
In this example, the empty lines make it hard to see the relationships between parts. Which part executes the code? Which part confirms the behavior? Which part sets up the test?
1๏ธโฃ
describe('KeyringController', () => {
describe('submitEncryptionKey', () => {
it('unlocks the keyrings with valid information', async () => {
const keyringController = await initializeKeyringController({
password: 'password',
});
keyringController.cacheEncryptionKey = true;
const returnValue = await keyringController.encryptor.decryptWithKey();
const decryptWithKeyStub = sinon.stub(
keyringController.encryptor,
'decryptWithKey',
);
decryptWithKeyStub.resolves(Promise.resolve(returnValue));
keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA });
await keyringController.setLocked();
await keyringController.submitEncryptionKey(
'mockEncryptionKey',
'mockEncryptionSalt',
);
expect(keyringController.encryptor.decryptWithKey.calledOnce).toBe(true);
expect(keyringController.keyrings).toHaveLength(1);
});
});
});This version is clearer. All setup code is grouped together visually, making the phases easy to identify:
2๏ธโฃ
describe('KeyringController', () => {
describe('submitEncryptionKey', () => {
it('unlocks the keyrings with valid information', async () => {
const keyringController = await initializeKeyringController({
password: 'password',
});
keyringController.cacheEncryptionKey = true;
const returnValue = await keyringController.encryptor.decryptWithKey();
const decryptWithKeyStub = sinon.stub(
keyringController.encryptor,
'decryptWithKey',
);
decryptWithKeyStub.resolves(Promise.resolve(returnValue));
keyringController.store.updateState({ vault: MOCK_ENCRYPTION_DATA });
await keyringController.setLocked();
await keyringController.submitEncryptionKey(
'mockEncryptionKey',
'mockEncryptionSalt',
);
expect(keyringController.encryptor.decryptWithKey.calledOnce).toBe(true);
expect(keyringController.keyrings).toHaveLength(1);
});
});
});Read more
- "Arrange, act, assert" is a simplification of the four-phase test.
- In behavior-driven development, this is also called "given, when, then".
Keep tests isolated
A test must always pass. Running a test by itself or with a group of other tests must not change the result.
Tests must run in a clean environment. If a test changes any part of the environment, it must undo those changes before finishing. This prevents the test from affecting other tests.
The next sections suggest ways to keep tests isolated.
Restore function mocks after each test
Set Jest's resetMocks and restoreMocks configuration options to true. Jest will then reset all mock functions and return them to their original implementations after each test. (MetaMask's module template includes this setting.)
Read more
Mock functions that are visible to multiple tests must be reset properly. Otherwise, their state will affect other tests.
This example has two tests. The second test assumes the spy on getNetworkStatus from the first test is removed, but it is not:
๐ซ
const optionsMock = {
getNetworkStatus: () => 'available',
};
describe('token-utils', () => {
it('returns null if the network is still loading', () => {
// Oops! This changes the return value of this mock for every other test
jest.spyOn(optionsMock, 'getNetworkStatus').mockReturnValue('loading');
expect(getTokenDetails('0xABC123', optionsMock)).toBeNull();
});
it('returns the details about the given token', () => {
// This will likely not work as `getNetworkStatus` still returns "loading"
expect(getTokenDetails('0xABC123', optionsMock)).toStrictEqual({
standard: 'ERC20',
symbol: 'TEST',
});
});
});You can save the spy to a variable and call mockRestore on it before the test ends:
const optionsMock = {
getNetworkStatus: () => 'available',
};
describe('token-utils', () => {
it('returns null if the network is still loading', () => {
const getNetworkStatusSpy = jest
.spyOn(optionsMock, 'getNetworkStatus')
.mockReturnValue('loading');
expect(getTokenDetails('0xABC123', optionsMock)).toBeNull();
getNetworkStatusSpy.mockRestore();
});
it('returns the details about the given token', () => {
expect(getTokenDetails('0xABC123', optionsMock)).toStrictEqual({
standard: 'ERC20',
symbol: 'TEST',
});
});
});However, the better approach is to set resetMocks and restoreMocks in your Jest configuration. Jest will then handle this automatically.
Reset global variables
Create a helper function that wraps the code under test. This ensures changes to global variables are undone after use.
When possible, use dependency injection to pass globals to the code under test. This lets you pass mock implementations in tests.
Read more
Global variables are properties of the global context (usually global). Changes to these variables affect every test in a test file.
If you want to change a global function in a test, mock it using jest.spyOn. This makes the mock easy to reset later.
๐ซ
describe('NftDetails', () => {
it('opens a tab', () => {
// This change will apply to every other test that's run after this one
global.platform = { openTab: jest.fn() };
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
await waitFor(() => {
expect(global.platform.openTab).toHaveBeenCalledWith({
url: 'https://testnets.opensea.io/assets/goerli/0xABC123/1',
});
});
});
it('renders the View Opensea button after opening a tab', () => {
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
// Oops! `global.platform.openTab` will still be a mock function, so
// if this element is supposed to appear after opening a tab, it won't
expect(queryByTestId('nft-options__view-on-opensea')).toBeInTheDocument();
});
});โ
describe('NftDetails', () => {
it('opens a tab', () => {
// Now, as long as we set set Jest's `restoreMocks` configuration option to
// true, this should work as expected
jest.spyOn(global.platform, 'openTab').mockReturnValue();
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
await waitFor(() => {
expect(global.platform.openTab).toHaveBeenCalledWith({
url: 'https://testnets.opensea.io/assets/goerli/0xABC123/1',
});
});
});
it('renders the View Opensea button after opening a tab', () => {
const { queryByTestId } = render(<NftDetails />);
fireEvent.click(queryByTestId('nft-options__button'));
// `global.platform.openTab` should no longer be mocked, so this will appear
expect(queryByTestId('nft-options__view-on-opensea')).toBeInTheDocument();
});
});If you want to change a global property that is not a function, do not assign it directly in a test:
๐ซ
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
// This change will apply to every other test that's run after this one
global.ethereumProvider = new HttpProvider(
'https://sepolia.infura.io/v3/abc123',
);
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
// Oops! This will still hit Sepolia
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});Instead of using hooks, create a function that wraps your test. This function captures the current global value before the test and restores it afterward:
1๏ธโฃ
async function withEthereumProvider(
ethereumProvider: Provider,
test: () => void | Promise<void>,
) {
const originalEthereumProvider = global.ethereumProvider;
global.ethereumProvider = ethereumProvider;
await test();
global.ethereumProvider = originalEthereumProvider;
}
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
const sepolia = new HttpProvider('https://sepolia.infura.io/v3/abc123');
withEthereumProvider(sepolia, () => {
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
// Now this test will "just work"
expect(interpretMethodData('0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});A better approach is to use dependency injection to remove the need for a global variable. This lets you create a fake value within your test:
2๏ธโฃ
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
const provider = createFakeProvider({ network: 'sepolia' });
expect(interpretMethodData(provider, '0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
const provider = createFakeProvider({ network: 'mainnet' });
expect(interpretMethodData(provider, '0x3fbac0ab')).toStrictEqual({
name: 'Set Approval For All',
});
});
});Reset shared variables
Use helper functions instead of variables to define data shared between tests.
Read more
Variables declared outside of tests are not reset automatically between tests. Changes to these variables in one test can affect other tests. This breaks test isolation.
Example:
๐ซ
const NETWORK = {
provider: new HttpProvider('https://mainnet.infura.io/v3/abc123');
};
describe("interpretMethodData", () => {
it("returns the signature for setApprovalForAll on Sepolia", () => {
// This change will apply to every other test that's run after this one
NETWORK.provider = new HttpProvider(
'https://sepolia.infura.io/v3/abc123',
);
expect(interpretMethodData('0x3fbac0ab', NETWORK)).toStrictEqual({
name: 'Set Approval For All'
});
});
it("returns the signature for setApprovalForAll on Mainnet", () => {
// Oops! This will still hit Sepolia
expect(interpretMethodData('0x3fbac0ab', NETWORK)).toStrictEqual({
name: 'Set Approval For All'
});
});
});Using beforeEach might seem like a solution:
๐ซ
describe("interpretMethodData", () => {
const network;
beforeEach(() => {
// Now this variable is reset before each test
network = {
provider: new HttpProvider('https://mainnet.infura.io/v3/abc123');
};
});
it("returns the signature for setApprovalForAll on Sepolia", () => {
// This change will no longer apply to every other test
network.provider = new HttpProvider(
'https://sepolia.infura.io/v3/abc123',
);
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All'
});
});
it("returns the signature for setApprovalForAll on Mainnet", () => {
// This will use Mainnet, as expected
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All'
});
});
});Instead of using hooks, use a factory function. This function should define default values and let you override them when needed:
โ
function buildNetwork({
provider = HttpProvider('https://mainnet.infura.io/v3/abc123'),
}: Partial<{
provider: HttpProvider;
}> = {}) {
return { provider };
}
describe('interpretMethodData', () => {
it('returns the signature for setApprovalForAll on Sepolia', () => {
// Now `network` only lives for the duration of the test, so it cannot be
// shared among other tests
const network = buildNetwork({
provider: new HttpProvider('https://sepolia.infura.io/v3/abc123'),
});
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All',
});
});
it('returns the signature for setApprovalForAll on Mainnet', () => {
// This test will use Mainnet by default thanks to how we defined the helper
const network = buildNetwork();
expect(interpretMethodData('0x3fbac0ab', network)).toStrictEqual({
name: 'Set Approval For All',
});
});
});Learn more
Avoid the use of `beforeEach`
Extract shared setup steps into functions instead of putting them in a beforeEach hook:
๐ซ
describe('TokenDetectionController', () => {
let getCurrentChainId: jest.mock, []>;
let preferencesController: PreferencesController;
let tokenDetectionController: TokenDetectionController;
beforeEach(() => {
getCurrentChainId = jest.fn().mockResolvedValue('0x1');
preferencesController = new PreferencesController({
getCurrentChainId,
});
tokenDetectionController = new TokenDetectionController({
preferencesController,
});
});
describe('constructor', () => {
it('sets default state', () => {
expect(tokenDetectionController.state).toStrictEqual({
tokensByChainId: {},
});
});
});
describe('detectTokens', () => {
it('tracks tokens for the currently selected chain', async () => {
const sampleToken = { symbol: 'TOKEN', address: '0x2' };
getCurrentChainId.mockResolvedValue('0x2');
jest
.spyOn(tokenDetectionController, 'fetchTokens')
.mockResolvedValue(['0xAAA', '0xBBB']);
await tokenDetectionController.detectTokens();
expect(
tokenDetectionController.state.tokensByChainId['0x2'],
).toStrictEqual(['0xAAA', '0xBBB']);
});
});
});โ
describe('TokenDetectionController', () => {
describe('constructor', () => {
it('sets default state', () => {
const { controller } = buildTokenDetectionController();
expect(controller.state).toStrictEqual({
tokensByChainId: {},
});
});
});
describe('detectTokens', () => {
it('tracks tokens for the currently selected chain', async () => {
const sampleToken = { symbol: 'TOKEN', address: '0x2' };
const { controller } = buildTokenDetectionController({
getCurrentChainId: () => '0x2',
});
jest
.spyOn(controller, 'fetchTokens')
.mockResolvedValue(['0xAAA', '0xBBB']);
await controller.detectTokens();
expect(controller.state.tokensByChainId['0x2']).toStrictEqual([
'0xAAA',
'0xBBB',
]);
});
});
});
function buildTokenDetectionController({
getCurrentChainId = () => '0x1',
}: {
getCurrentChainId?: () => string;
}) {
const preferencesController = new PreferencesController({
getCurrentChainId,
});
const tokenDetectionController = new TokenDetectionController({
preferencesController,
});
return { controller: tokenDetectionController, preferencesController };
}Read more
Using a beforeEach hook to set up tests with similar needs might seem convenient. However, this approach increases maintenance costs for two reasons:
- It makes tests harder to read. Different tests may need different setup, but
beforeEachassigns equal importance to all setup steps. Readers must read all the setup code to find what matters for each test. - It makes writing new tests difficult. The
beforeEachsetup may not fit new test scenarios. You may need complex refactoring to remove steps that don't apply, or use workarounds that hurt consistency and readability.
Setup helper functions solve these problems in two ways:
- Tests can pass options to the function to specify the setup they need, showing readers what is important for each test.
- Setup code is easier to refactor when needed.
Helper functions work for teardown too, not just setup. Consider the following example, which uses beforeEach to create a controller and afterEach to destroy it:
๐ซ
describe('TokensController', () => {
let controller: TokensController;
let onNetworkDidChangeListener: (
networkState: NetworkState,
) => void | undefined;
beforeEach(() => {
onNetworkDidChange = jest.fn();
controller = new TokensController({
chainId: '0x1',
onNetworkDidChange: (listener) => {
onNetworkDidChangeListener = listener;
},
});
});
afterEach(() => {
controller.destroy();
});
describe('addToken', () => {
it('registers the given token under the default network, assuming it has not changed yet', () => {
controller.addToken({ symbol: 'DAI' });
expect(controller.state.tokens).toStrictEqual({
'0x1': {
symbol: 'DAI',
},
});
});
it('registers the given token under the current network, even after it changes', () => {
onNetworkDidChangeListener!({ chainId: '0x42' });
controller.addToken({ symbol: 'DAI' });
expect(controller.state.tokens).toStrictEqual({
'0x42': {
symbol: 'DAI',
},
});
});
});
});A more maintainable pattern is a function that wraps the test, automatically creating and destroying the controller before and after it runs:
โ
/* Detailed source-code truncated for AI context efficiency. */Keep critical data in the test
Tests often use data that is essential for setup or verification.
Keep this data inside the test instead of spreading it across the file or project. This makes the test easier to understand.
๐ซ
/* Detailed source-code truncated for AI context efficiency. */โ
describe('TokensController', () => {
describe('start', () => {
it('loads the token list for the selected chain', async () => {
// Now all of the data that the test ends up using in the execution and
// verification phases is clearly spelled out. This also gives us an
// opportunity to simplify the test setup.
const chainIdInHex = '0x1';
const chainIdInDecimal = '1';
const tokensByAddress = {
'0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': {
address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f',
symbol: 'SNX',
decimals: 18,
occurrences: 11,
name: 'Synthetix',
iconUrl: 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png',
aggregators: ['Aave', 'Bancor', 'CMC'],
};
};
nock(TOKEN_END_POINT_API)
.get(`/tokens/${chainIdInDecimal}`)
.reply(200, Object.values(tokensByAddress));
const controller = new TokenListController({
chainId: chainIdInHex,
});
await controller.start();
expect(controller.state.tokenList).toStrictEqual(tokensByAddress);
expect(
controller.state.tokensChainsCache[chainIdInHex].data,
).toStrictEqual(tokensByAddress);
});
});
});Use Jest's mock functions instead of Sinon
Jest includes most of Sinon's features with a simpler API:
- Use
jest.fn()instead ofsinon.stub(). - Use
jest.spyOn(object, method)instead ofsinon.spy(object, method)orsinon.stub(object, method). (Note: The spied method will still be called by default.) - Use
jest.useFakeTimers()instead ofsinon.useFakeTimers(). (Note: Jest's "clock" object had fewer features than Sinon's before Jest v29.5.)
General manual mocks
Jest's documentation states: "Manual mocks are defined by writing a module in a __mocks__/ subdirectory immediately". Jest automatically picks up these mocks for all tests. Be very careful when writing manual mocks because they are shared across all tests (including UI integration tests).
Snapshots
Jest snapshots do not test whether a value is valid. They only check for changes since the last snapshot was created.
Do not consider snapshot matching as a full test of a component. Snapshots only verify that the component rendered without errors. The snapshot might show an error screen instead of the actual component.
Name your snapshot test cases clearly:
๐ซ Wrong naming
describe('MyComponent', () => {
it('should renders correctly', () => {
// ...
});
});โ Correct naming
describe('MyComponent', () => {
it('matches rendered snapshot', () => {
// ...
});
});You can use variants of this naming to add context. For example:
describe('MyComponent', () => {
it('matches rendered snapshot when not enabled', () => {
// ...
});
});File: docs/engineering-principles.md
Engineering Principles
Engineering principles define the core tenets and beliefs that the engineering team and all contributors are expected to keep front of mind. These tenets are meant to reflect the values of the organization.
Core Engineering Values
These are the qualities that we strive for in our software. (These values aren't ranked, but are all of equal importance.)
- Security
- Stability
- Privacy
- Maintainability
- Usability
- Transparency
Principles
The Principle of Least Authority
Provide application code the absolute minimal amount of authority required to perform its essential functions. Wherever appropriate, break down large methods with broad authority into smaller functions with minimal authority. Minimizing what a method can do reduces the scope of what could possibly go wrong. When something does go wrong, small, isolated methods, written with the least amount of authority required, are easier to troubleshoot.
The Principle of Incremental Change
To minimize the risk to the stability and security of the application, changes to the code should be incremental and as small as possible. Large changes to the codebase become difficult to review effectively and make the commit history less itemized. Furthermore, incremental change promotes better test coverage and makes it easier to require tests to cover the introduced changes.
The Principle of Incremental Decentralization
We are trying to build a decentralized future and we must make choices that move us directionally towards those goals. This means that users should be able to swap centralized services out for decentralized alternatives. Additionally, when we build features that rely upon centralized services we must build them in such a way where the experience isn't completely broken when the service is disrupted.
The Principle of Self-Sovereign Identity
We believe in the right of our users to own their own identity and data. We must go to great lengths to protect that right. Where we have features or services that might infringe upon that right, we must treat those features as opt-in, not opt-out. When we do build features that make user data known to us, we must not collect any personally identifiable information, and any data that can be tied to any chain must be collected in an anonymity-protecting way. This principle must be extended to cover all third-party code as well.
The Principle of Auditability
As we are committed to providing a high level of security and privacy within our product, we must also maintain an auditable, publicly accessible, and transparent codebase. We mustn't ask our users to trust our products implicitly, but instead, write code that explicitly illustrates our values. In order for our codebase to be easily auditable, our code must have the following qualities:
- Readability - Our code should be easily read, and be free of obfuscation or subterfuge.
- Consistency - Our code should follow consistent standards to assist auditors in tracing code paths across multiple repositories or code from varying authors.
- Usability - Our code must maintain a standard of high quality and usability, and reflect the qualities listed in the values section above.
- Traceability - Our code should have a public record of changes made, with details about why the change was made.
The Principle of "Be Methodical and Fix Things"
We should prioritize fixing bugs and improving stability over creating new features whenever possible. All code generates new bugs and builds technical debt, this is inevitable. However, if we focus on building our features with stability in mind and throw out the "Move Fast and Break Things" mentality of most web 2.0 applications we can minimize the amount of bugs we introduce. Furthermore, whenever bugs are known to exist we should expend the necessary energy to remedy them.
The Principle of Reliability
We provide a plethora of features in our applications that users expect to be supported, and that they rely upon for their day-to-day activities. We must handle feature deprecation gracefully, and inform users well in advance that a feature is being sunsetted or replaced. If we remove features or experiences from our product without warning it will alienate our users and cause them to seek out other solutions.
We also provide external facing APIs that app developers utilize to build their products. Every change we make to these APIs must be analyzed for potentially breaking functionality of the apps in our ecosystem. When we make breaking changes we are creating work for countless other organizations and individuals, and only by their good graces will they make the requested changes. We must view these breaking changes as a potential reset of our momentum within the ecosystem. Breaking changes should only be made for critical reasons of security or stability that cannot be made by any other means.
When removing a feature or making a breaking change to an API is necessary, then a generous migration period must be planned and ecosystem or user outreach must occur to ensure a smooth transition.
File: docs/javascript.md
JavaScript Guidelines
Use a verb phrase for function/method names
When naming a function or method, describe the action, not the outcome.
๐ซ
function formattedChangelog() {
// ...
}โ
function formatChangelog() {
// ...
}For a function or method that returns a boolean, reword the name to start with a descriptive verb (e.g. getNOUN as opposed to isSTATE) so that it doesn't collide with a variable that shares its name, which would trigger the ESLint no-shadow rule:
๐ซ
function isEIP1559Compatible() {
// ...
}
const isEIP1559Compatible = isEIP1559Compatible();โ
function getEIP1559Compatibility() {
// ...
}
const isEIP1559Compatible = getEIP1559Compatibility();Read more
- Google's JavaScript Style Guide
- .NET Fundamentals
- Kotlin Coding Conventions
- Swift API Design Guidelines
Use a verb phrase for boolean variable names that do not involve secondary objects
Sometimes a variable that is intended to hold a boolean value does not have an explicit subject, but represents the context where the variable is defined (e.g., a class or an entire file). When naming such a variable, use a statement that describes the state of the subject, minus the name of the subject itself. Usually this means prefixing the name with a form of "to be" or "to have" (e.g. is*, has*), but you may find it more readable to use past or future tense and/or a modal verb such as should.
๐ซ
const removed = false;โ
// Any of these would do
const isRemoved = false;
const wasRemoved = false;
const hasBeenRemoved = false;
const shouldBeRemoved = false;If the name represents a negative statement, reword it into a positive statement and inverting the value assigned to the value.
๐ซ
const notEnoughGas = false;โ
const hasEnoughGas = true;Take special note of variables which are created via React's useState hook.
๐ซ
const [removed, setRemoved] = useState(false);โ
const [isRemoved, setIsRemoved] = useState(false);๐ก Place names of secondary concepts first in boolean variable names
When naming a boolean variable that includes a subject, the previous guideline suggests that you can place the verb at the beginning:
const isRecipientOwnedAccount = Boolean(ownedAccountName);However, this naming strategy creates a point of friction for objects, arrays, or React components, where it may be desirable to sort identifiers alphabetically. In that case you could end up with something like:
<SenderToRecipient
isRecipientOwnedAccount={isRecipientOwnedAccount}
onClick={onClick}
recipientName={toName}
recipientNickname={toNickname}
senderAddress={fromAddress}
senderName={fromName}
/>It is potentially easier to read if properties that concern the same concept are kept together instead of separate. To address this, you may wish to place the subject of the variable name at the beginning:
const recipientIsOwnedAccount = Boolean(ownedAccountName);This would result in:
<SenderToRecipient
recipientIsOwnedAccount={isRecipientOwnedAccount}
recipientName={toName}
recipientNickname={toNickname}
onClick={onClick}
senderAddress={fromAddress}
senderName={fromName}
/>Use `async`/`await` syntax over `.then`/`.catch`
Asynchronous code written using async/await syntax looks less complex and more straightforward than code written using .then/.catch. Additionally, using async/await leads to better stack traces in Node and Chromium, both of which use the V8 JavaScript engine, because when an asynchronous operation is awaited, the engine will remember the function where the await occurred, which means that it can place that function on the stack trace (otherwise, using .then/.catch, it would get lost).
๐ซ
function makeRequest() {
return fetch('https://google.com').then((response) => {
return response.json().then((json) => {
return json['page_views'];
});
});
}โ
async function makeRequest() {
const response = await fetch('https://google.com');
const json = await response.json();
return json['page_views'];
}`await` promises before returning them
An async function that returns a rejected promise created via another async function may disappear from the stack trace. This is solved by awaiting the promise before returning it.
Read more
If you save the following to a file (say, /tmp/example.js) and run it with node:
async function foo() {
return bar();
}
async function bar() {
await Promise.resolve();
throw new Error('BEEP BEEP');
}
foo().catch((error) => console.log(error.stack));then you will see the following in the terminal (as of Node 18):
Error: BEEP BEEP
at bar (/private/tmp/example.js:7:9)Notice how foo is completely missing from the stack trace!
However, if you put an await before the call to bar:
async function foo() {
return await bar();
}
async function bar() {
await Promise.resolve();
throw new Error('BEEP BEEP');
}
foo().catch((error) => console.log(error.stack));you will now see it at the bottom of the stack trace:
Error: BEEP BEEP
at bar (/private/tmp/example.js:7:9)
at async foo (/private/tmp/example.js:2:10)๐ซ
async function makeRequest() {
const response = await fetch('https://some/url');
return response.json();
}โ
async function makeRequest() {
const response = await fetch('https://some/url');
return await response.json();
}