I'm going to try to answer your question in a generalised manner, however listing the methods in descending order of relevance to your requirement. Here's a guide on how to speed up your Hardhat test runs:
1. Use a Faster Local Ethereum Node
The default local node provided by Hardhat uses the ethereumjs EVM, which can be relatively slow. Consider switching to a faster local node for improved performance.
Anvil: A High-Performance Local Node
Anvil is a local Ethereum node designed for development. Notably, it uses the revm, an EVM implemented in Rust, which is known for its performance. See ziyadedher/evm-bench for a performance comparison of different EVM implementations.
To integrate Anvil with Hardhat, there's a plugin named hardhat-anvil. It's recommended to set launch: false
and spawn anvil
manually in a separate shell which you can do using the following command:
anvil --prune-history --order fifo --code-size-limit 4294967296 -m "test test test test test test test test test test test junk" --gas-limit 100000000000
For a complete list of all anvil
options see: https://book.getfoundry.sh/reference/anvil/#options
Additionally, you can save some time by disabling transaction signature verification using the eth_sendUnsignedTransaction
method that accepts the same parameters as eth_call
:
await network.provider.send("eth_sendUnsignedTransaction", [{
data,
from,
to,
gas,
gasPrice
}]);
2. Leverage Parallel Test Execution
Hardhat supports Mocha's parallel test execution. Running tests in parallel can considerably reduce the overall execution time, especially if you have a multi-core CPU.
Steps:
Mocha's parallel execution runs tests in separate files concurrently. Therefore, to maximize this feature, if you have multiple test cases(e.g. for a kind of property-based test) for a given test function you can distribute/partition these test cases across multiple files.
Steps:
Create multiple duplicate test files, for example, named test-foo-{i}.spec.ts
where {i}
is the file number.
Partition your test cases equally among these files. To automate this:
import { getTestCases } from './getTestCases';
const testCases = getTestCases(); // if you have 70 test cases then these test cases will be split 10 per file
const totalFiles = 7; // if mocha is able to process 7 files concurrently then you can create 7 duplicates of the same file
function getFileIndex(): number {
const fileName = __filename.split('-').pop()?.split('.')[0] ?? '1';
return parseInt(fileName, 10);
}
const totalCases = testCases.length;
const casesPerFile = Math.ceil(totalCases / totalFiles);
const startIndex = (getFileIndex() - 1) * casesPerFile;
const endIndex = Math.min(startIndex + casesPerFile, totalCases);
const fileTestCases = testCases.slice(startIndex, endIndex); // the test cases for this file to process
// Now run the tests using these cases
Ensure that your test cases are optimized:
- Avoid redundancy: Ensure there's no overlapping or duplicated test logic.
- Mock external calls where possible to reduce external dependencies and network calls. Don't use a hardhat network fork unnecessarily.
NB: If you're using a local node other than the hardhat local node(e.g. anvil
) then when using hardhat --parallel
you must ensure that each file is using a separate set of accounts to avoid nonce collisions. This isn't an issue when using hardhat --parallel
with hardhat being the configured network as hardhat likely spawns multiple hardhat nodes for each test script or maybe doesn't check the transaction nonces.
3. Offloading Test Logic to Solidity:
Instead of implementing complex test logic in TypeScript, consider using helper Solidity contracts to encapsulate and execute that logic.
Steps:
a. Create Helper Contract: Write Solidity test contract/s that mimics the operations you want to test. For example, if you're testing token transfers, the test contract could manage multiple transfers in one function.
b. Interact with Helper: In your TypeScript tests, deploy and interact with this helper contract instead of sending multiple individual transactions.
c. Batch Operations: Use the helper contract to batch multiple operations into single transactions.
Benefits:
- Efficiency: Solidity can often perform batch operations faster than multiple individual transactions in TypeScript.
- Simplicity: TypeScript tests become cleaner, focusing on high-level operations.
4. Specifying gasLimit
and gasPrice
:
By manually specifying these values, you can avoid the overhead of eth_estimateGas
and eth_gasPrice
calls. To avoid transactions failing due to a too low override be careful to set these values sufficiently high.
Usage:
await contract.functionName(args, {
gasLimit: 10_000_000,
gasPrice: ethers.utils.parseUnits("1", "gwei")
});
You could also consider setting this globally in the hardhat network config using the blockGasLimit
, gas
and gasPrice
fields.
5. Snapshotting & Restoring (and Using Fixtures):
Snapshotting and restoring allow you to capture the entire state of the blockchain at a given point and revert to that state at will.
Usage:
- Before a set of tests that require a certain setup (e.g., deploying specific contracts and initializing states), take a snapshot.
- After running the tests, revert to the snapshot. This avoids the need to redo the setup for each subsequent test.
How to Implement:
const snapshotId = await ethers.provider.send("evm_snapshot", []);
// ... tests or operations ...
await ethers.provider.send("evm_revert", [snapshotId]);
Benefits:
- Efficiency: Avoids redundant deployments and state initializations.
- Clean Environment: Ensures each test starts with a consistent environment, minimizing side effects.
Automate Snapshots with Fixtures:
Use loadFixture to automate the process of taking and restoring a snapshot.
6. Optimize TypeScript Code:
The way your TypeScript tests are written can impact the test execution time. Here are some optimizations:
a. Batch Transactions:
If the order of transactions isn't critical, send them simultaneously. You can also do this for eth_call
requests when calling view
functions or staticcalls to stateful functions.
await Promise.all([
contractA.functionA(args),
contractB.functionB(args),
// ...
]);
b. Cache Results:
If you're repeatedly fetching the same data from the blockchain, consider caching it in a variable.
const tokenName = await tokenContract.name();
c. Limit Chain Queries:
Reduce unnecessary queries to the chain. If possible, calculate or infer results without additional chain queries.
7. Continuous Integration (CI) Improvements
If you use a CI/CD system like GitHub Actions, ensure:
- Your CI environment is optimized for speed.
- Use caching for dependencies to reduce installation times.
- Parallelize CI tasks where possible.
require
s, mapping assignmentsmapping[tokenId] = value
, and then just calling OpenZeppelin's standard ERC721_safeMint
internally. if I call this 100 times (which should be literally nothing computationally) it takes a few seconds to run. the length is directly proportional to how many times_safeMint
is called. – Soulier