Skip to main content

Test Framework Overview

ENS v2 uses a dual testing approach:
  • Forge (Foundry): Solidity-based unit tests for core contract logic
  • Hardhat + Vitest: TypeScript integration tests for complex scenarios
  • End-to-End (E2E): Full stack tests using the devnet

Prerequisites

Before running tests, compile the ens-contracts library:
cd lib/ens-contracts
bun run compile
cd ../..

Running Tests

All Tests

Run the complete test suite:
bun run test
This executes both Forge and Hardhat tests sequentially.

Forge Tests

Run Solidity unit tests using Foundry:
bun run test:forge
# or
forge test

Hardhat Tests

Run TypeScript integration tests:
bun run test:hardhat
Hardhat tests are configured in vitest.config.ts:
export default defineConfig({
  test: {
    reporters: ["verbose"],
    include: ["test/integration/**/*.test.ts"],
    setupFiles: ["test/integration/vitest-setup.ts"],
  },
});

End-to-End Tests

Run E2E tests against a local devnet:
bun run test:e2e
E2E tests are located in test/e2e/ and include:
  • devnet.test.ts: Devnet functionality tests
  • resolve.test.ts: Name resolution tests

Test Coverage

Generate Coverage Reports

1

Run Forge coverage

bun run coverage:forge
Generates coverage/forge.lcov
2

Run Hardhat coverage

bun run coverage:hardhat
Generates coverage data for TypeScript tests
3

Generate combined reports

bun run coverage:reports
4

Run all coverage (recommended)

bun run coverage
Runs all three commands above in sequence
Coverage requires lcov to be installed. See Setup for installation instructions.

Writing Tests

Forge Test Guidelines

Forge tests are located in files ending with .t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Test} from "forge-std/Test.sol";
import {PermissionedRegistry} from "src/registry/PermissionedRegistry.sol";
import {RegistryRolesLib} from "src/registry/libraries/RegistryRolesLib.sol";

contract PermissionedRegistryTest is Test {
    PermissionedRegistry public registry;
    address public owner = address(1);
    address public user = address(2);

    function setUp() public {
        // Setup runs before each test
        registry = new PermissionedRegistry(
            hcaFactory,
            metadata,
            owner,
            RegistryRolesLib.ALL_ROLES
        );
    }

    function testRegisterName() public {
        vm.prank(owner);
        uint256 tokenId = registry.register(
            "test",
            user,
            address(0),
            address(0),
            0,
            type(uint64).max
        );
        
        assertEq(registry.ownerOf(tokenId), user);
    }
}

Event Testing

Never use vm.expectEmit() for event testing. Always use vm.recordLogs() and verify logs manually.
function testEventEmission() public {
    vm.recordLogs();
    
    // Perform action that emits events
    registry.register("test", user, address(0), address(0), 0, type(uint64).max);
    
    // Get and verify logs
    Vm.Log[] memory logs = vm.getRecordedLogs();
    assertEq(logs.length, 2); // Transfer + NewSubname
    
    // Verify specific event data
    assertEq(logs[1].topics[0], keccak256("NewSubname(uint256,string)"));
}

vm.prank and vm.expectRevert

When using vm.prank and vm.expectRevert together, always place vm.expectRevert before vm.prank.
// Correct order
function testUnauthorizedAccess() public {
    vm.expectRevert(abi.encodeWithSignature("Unauthorized()"));
    vm.prank(user);
    registry.adminFunction();
}

// Wrong order - will fail
function testUnauthorizedAccessWrong() public {
    vm.prank(user);  // Don't do this
    vm.expectRevert(abi.encodeWithSignature("Unauthorized()"));
    registry.adminFunction();
}

Using Defined Constants

Always use constants defined in source contracts instead of hardcoding values in tests.
import {RegistryRolesLib} from "src/registry/libraries/RegistryRolesLib.sol";

// Good - uses defined constant
function testGrantRole() public {
    registry.grantRoles(tokenId, RegistryRolesLib.ROLE_SET_RESOLVER, user);
}

// Bad - hardcoded value
function testGrantRoleBad() public {
    registry.grantRoles(tokenId, 1 << 12, user);  // Don't do this
}

Hardhat Test Guidelines

Hardhat tests use Vitest and are written in TypeScript:
import { beforeAll, describe, expect, it } from "vitest";
import { parseEther } from "viem";

describe("ETHRegistrar", () => {
  let env: DevnetEnvironment;
  let owner: Account;

  beforeAll(async () => {
    env = await setupDevnet();
    owner = env.namedAccounts.owner;
  });

  it("should register a name", async () => {
    const label = "test";
    const duration = 365n * 86400n; // 1 year

    await env.deployment.contracts.ETHRegistrar.write.register(
      [label, owner.address, duration],
      { value: parseEther("0.01") }
    );

    const tokenId = await env.deployment.contracts.ETHRegistry.read.getTokenId(
      [label]
    );
    
    expect(
      await env.deployment.contracts.ETHRegistry.read.ownerOf([tokenId])
    ).toBe(owner.address);
  });
});

Fuzz Testing

Forge includes built-in fuzzing support. Configure in foundry.toml:
[fuzz]
runs = 4096
Write fuzz tests by adding parameters to test functions:
function testRegisterFuzz(string calldata label, address owner) public {
    // Assumptions to constrain inputs
    vm.assume(bytes(label).length > 0 && bytes(label).length < 256);
    vm.assume(owner != address(0));
    
    // Test with random inputs
    uint256 tokenId = registry.register(
        label,
        owner,
        address(0),
        address(0),
        0,
        type(uint64).max
    );
    
    assertEq(registry.ownerOf(tokenId), owner);
}

Test Organization

Directory Structure

test/
├── *.t.sol                    # Forge unit tests
├── integration/               # Hardhat integration tests
│   ├── vitest-setup.ts       # Test setup
│   ├── fixtures/             # Test fixtures
│   │   ├── deployV1Fixture.ts
│   │   ├── deployV2Fixture.ts
│   │   └── deployVerifiableProxy.ts
│   ├── ENSV1Resolver.test.ts
│   ├── ENSV2Resolver.test.ts
│   └── dns/
│       ├── DNSTLDResolver.test.ts
│       └── DNSTLDResolver.mainnet.test.ts
├── e2e/                      # End-to-end tests
│   ├── test-setup.ts
│   ├── devnet.test.ts
│   └── resolve.test.ts
├── mocks/                    # Mock contracts
└── utils/                    # Test utilities
    ├── expectVar.ts
    ├── resolutions.ts
    └── waitForSuccessfulTransactionReceipt.ts

Test Categories

  • Unit tests (.t.sol): Test individual contract functions in isolation
  • Integration tests (integration/*.test.ts): Test interactions between contracts
  • E2E tests (e2e/*.test.ts): Test complete workflows on devnet
  • Mainnet fork tests (*.mainnet.test.ts): Test against mainnet state

Debugging Tests

Forge Debugging

forge test -vvvv --match-test testName

Console Logging

In Forge tests, use console.log from forge-std:
import {console} from "forge-std/console.sol";

function testWithLogging() public {
    console.log("Token ID:", tokenId);
    console.log("Owner:", owner);
    console.logBytes32(keccak256("test"));
}

Hardhat Debugging

In Hardhat tests, use standard JavaScript debugging:
it("should debug", async () => {
  console.log("Contract address:", contract.address);
  console.log("Balance:", await client.getBalance({ address: owner.address }));
});

Continuous Integration

Tests run automatically on GitHub Actions. See .github/workflows/main.yml for the CI configuration.

Next Steps

Local Devnet

Set up a local development network

Deployment

Deploy contracts to networks

Development Setup

Configure your environment

Contract Reference

Explore contract APIs

Build docs developers (and LLMs) love