back

Rick ·

Should I write smart contract tests in Solidity or TypeScript?

Dev tooling

Hardhat 3's own documentation answers this question with "both". Its testing overview says Solidity and TypeScript are both first-class options that "can be combined, and each brings their unique strengths", and the default project template ships contracts/Counter.t.sol and test/Counter.ts side by side.

That is true, and it is no help at all when you are about to write one test and have to pick a language.

So I built one project with both suites in it. Twelve test cases, written twice: once in Solidity against forge-std, once in TypeScript against viem. Then I introduced six deliberate defects into the contracts, one at a time, and ran both suites against each one.

Not one of the six was decided by the language. Two were caught by every selection I ran, because a plain example test is enough for them. One turned on where the input came from: I chose it, or a generator did. The other three turned on where the expected value came from — read back out of the contract under test, or written down independently of it — and for two of those three I could reproduce the catch in either language by changing nothing but that.

The setup

One Hardhat 3.16.0 project, so both runners compile the same contracts with the same compiler and the only variable is the test language. Two contracts:

  • ShareVault, a share-accounting model with deposit, withdraw, and an accrue function standing in for yield. Its interesting behaviour is integer rounding.
  • SignedTransfer, a balance ledger whose transfers are authorised by an EIP-712 signature. Its interesting behaviour is that the contract and the client have to agree on a hash neither of them owns.

The twelve shared cases are seven on the vault — first deposit, a deposit at a non-trivial share ratio, proportional withdrawal, two holders splitting yield, the Withdrawn event, an over-withdrawal revert, a zero-deposit revert — and five on the ledger: a valid signature, a wrong signer, a wrong nonce, a replayed signature, and an overspend. Each Solidity test has a TypeScript counterpart asserting the same thing.

On top of that, each side has cases the other does not: a fuzz test and a stateful invariant in Solidity, four tests in TypeScript that go through viem the way an application would, and two Solidity tests that state their expectation without asking the contract for it.

Versions were Hardhat 3.16.0, Node.js 22.17.0, viem 2.56.3, forge-std v1.16.2, and solc 0.8.25 with the optimizer at 200 runs. The machine has Foundry 1.5.1 installed; for the current release I downloaded the 1.8.1 archive, checked its SHA-256 against the published checksum, and ran it from a temporary directory rather than touching the installed toolchain. The machine was an Apple M3 Pro with 36 GB of memory. The project, the harnesses, and the raw results are in the repository under examples/articles/should-i-write-smart-contract-tests-in-solidity-or-typescript.

Twelve cases cost 124 lines of Solidity and 173 of TypeScript

Counting non-blank, non-comment lines, the Solidity suite is 124 lines and the TypeScript suite is 173 for the same twelve cases. That 40 percent difference is not really about verbosity. It comes from three specific things.

A Solidity test reads the return value of a state-changing function directly:

vm.prank(alice);
assertEq(vault.withdraw(1000), 2000);

A TypeScript test cannot. vault.write.withdraw(...) resolves to a transaction hash, so getting at the return value means simulating the same call separately:

const { result } = await vault.simulate.withdraw([400n], { account: alice });
assert.equal(result, 800n);

Second, every interaction is asynchronous, so the suite is a sequence of awaits rather than a sequence of statements. Third, the fixture has to exist: the Solidity runner deploys the test contract and calls setUp() for you, while the TypeScript suite needs an explicit fixture function and a loadFixture call to get a clean chain per test.

There were two small friction points I did not expect. Decoded log arguments come back checksummed while the wallet client reports its own address in lowercase, so comparing them needs getAddress on both sides. And the same test file mixes bigint literals for contract values with a plain number for the signature's v, which is easy to get subtly wrong.

None of this is hard. It is just more surface for the same assertion.

The same twelve tests, four ways to run them

Horizontal bar chart comparing the median wall-clock time of running the same twelve test cases four ways: forge test on Foundry 1.8.1 at 156 milliseconds, forge test on Foundry 1.5.1 at 365 milliseconds, hardhat test solidity on Hardhat 3.16.0 at 534 milliseconds, and hardhat test nodejs on Hardhat 3.16.0 at 1,134 milliseconds. Measured over 15 runs after two discarded warmups with the order rotated each run, on an Apple M3 Pro with Node.js 22.17.0 and solc 0.8.25.

Median wall clock of the whole command, including process start and the no-change compilation check. 15 measured runs after two discarded warmups, order rotated each run.

Within one Hardhat project, the TypeScript suite takes 1,134 ms against 534 ms for the Solidity one. That is 2.12 times as long, and 600 milliseconds in absolute terms. Variance was low on both: the Solidity command ranged from 522 to 545 ms and the TypeScript command from 1,130 to 1,142 ms.

The bigger spread is between Solidity runners. forge test on the same twelve .t.sol files takes 365 ms on Foundry 1.5.1 and 156 ms on the current 1.8.1 release, which is 2.3 times faster than the version that happened to be installed on my machine. From the fastest option to the slowest is a factor of 7.3 — and still only 978 milliseconds.

Hardhat's documentation says TypeScript "tests tend to run slower compared to Solidity because of the extra overhead of simulating a more realistic environment", which matches. What the ratio does not tell you is whether half a second matters. On a twelve-case suite, it does not. On a suite of six hundred cases, the same per-test overhead becomes the difference between a loop you stay inside and one you leave.

One thing that did stand out: hardhat test with no arguments runs both suites and prints a single summary line, 32 passing (16 solidity, 16 nodejs). Combining the two is genuinely one command, not two pipelines.

Then I broke the contract six times

Passing tests prove very little. So I wrote six mutations, each a single deliberate defect, applied one at a time to a pristine copy of the source, and recorded which test selections turned red.

Defect Solidity core Solidity fuzz + invariant Solidity, independent expectation TypeScript core TypeScript client
Deposit rounds shares up missed caught missed missed caught
Withdraw converts after burning shares caught caught caught caught caught
Withdrawn account no longer indexed missed missed caught missed caught
EIP-712 domain name changed missed — caught caught —
EIP-712 chain id hardcoded to 1 missed — caught caught —
Nonce never consumed caught — — caught —

A dash marks a selection that does not exercise the mutated contract at all, so it could not have caught the defect either way.

Two rows are controls, and they are the reason I trust the rest of the table. On the withdraw-ordering defect, the Solidity core suite and the TypeScript core suite each failed three tests, and they were the same three cases. On the nonce defect, each failed two, again the same two. The suites really are equivalent.

Randomised input caught the rounding bug, and Solidity had nothing to do with it

The first defect makes deposit round the minted shares up instead of down. Every hand-written test still passed — in both languages — because the numbers I had chosen divide evenly, and ceiling and floor agree when there is no remainder. That is how people write example tests.

The Solidity fuzz test and the invariant both failed immediately. So did the TypeScript test, because I had also written a loop over 256 pseudo-random inputs there. Randomised input is what found this defect. The language was irrelevant.

What the language changed was the cost and the quality of the report. On the unmutated contract, Foundry runs 256 fuzz cases plus 2,048 invariant calls in 59 milliseconds of in-suite execution. The hand-rolled TypeScript loop takes 335 milliseconds for 256 inputs and does strictly less: no call sequencing, no revert bookkeeping, no shrinking.

Shrinking is the part worth paying for. Given the same defect, Foundry shrank the failing invariant to a three-call sequence and printed it under a [Sequence] heading, identical across three consecutive runs (the per-run sender and handler address on each line are elided here):

calldata=deposit(uint256,uint256) args=[0, 1]
calldata=accrue(uint256) args=[1]
calldata=deposit(uint256,uint256) args=[0, 1]

Deposit one unit, accrue one unit of yield, deposit one unit again. That is the whole bug, and you can read it. Hardhat's Solidity runner caught the same defect, but reported four raw counterexample blocks with full-width fuzzed arguments and no shrunk sequence. Both runners tell you that you are wrong; only one of them tells you why in three lines.

This is also the one capability that really is exclusive. Foundry's docs are blunt about the scope of its runner — "Forge runs tests written in Solidity" — and Hardhat states that Solidity tests "are the only place where you can use built-in fuzzing and invariant testing". You can hand-roll a loop in TypeScript, and it works, but then you are writing a fuzzer instead of using one.

A client caught the signature bug, and TypeScript had nothing to do with it either

The next two defects break the EIP-712 domain: one changes the contract's name from SignedTransfer to Signed Transfer, the other hardcodes chainId to 1 instead of reading block.chainid. Both are real production bugs. The second makes signatures replayable across chains, and EIP-712 explicitly says the user-agent "should refuse signing if it does not match the currently active chain".

The Solidity core suite passed all twelve tests against both defects.

Diagram contrasting two paths to the same assertion. In the upper path a Solidity test calls the contract's own hashTransfer function to build the digest it then signs, so the expectation is derived from the contract and moves with it, and the test still passes after the domain changes. In the lower path a TypeScript test builds the digest with viem from the published EIP-712 domain name, version, chain id and verifying contract address, independently of the contract, so the signature no longer recovers and the test fails.

The same assertion, two sources for the expected value.

The reason is in that diagram, and it is not about Solidity. The shortest way to sign something in a Solidity test is to ask the contract for the digest and sign it:

function _sign(uint256 pk, address from, address to, uint256 amount, uint256 nonce)
    internal
    view
    returns (uint8 v, bytes32 r, bytes32 s)
{
    return vm.sign(pk, ledger.hashTransfer(from, to, amount, nonce));
}

That test agrees with the contract by construction. Change the domain inside the contract and the expectation changes with it. The test is not checking the domain at all; it is checking that ecrecover is self-consistent.

The TypeScript test signs with viem's signTypedData from the domain fields written down separately, so the digest is computed independently:

const signature = await wallet.signTypedData({
  domain: {
    name: "SignedTransfer",
    version: "1",
    chainId,
    verifyingContract: ledger.address,
  },
  types,
  primaryType: "Transfer",
  message: { from: alice, to, amount, nonce },
});

Three of its five signature tests failed on each defect. Not because TypeScript is better at cryptography, but because the expected value came from somewhere else.

And you can have that in Solidity too. I added one test that recomputes the domain separator from literal strings rather than reading it back from the contract, and it caught both defects on its own:

bytes32 expected = keccak256(
    abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256("SignedTransfer"),
        keccak256("1"),
        block.chainid,
        address(ledger)
    )
);
assertEq(ledger.DOMAIN_SEPARATOR(), expected, "domain separator does not match the published domain");

If you want the client to be the source of truth rather than a second copy of it, EIP-5267 standardises an eip712Domain() getter so applications can read the domain and build the separator generically. That moves the agreement problem rather than removing it: now the contract publishes its own domain and no test anywhere is checking that the published domain is the one users were told about.

The defect only two tests noticed

The third mutation removes indexed from the account parameter of the Withdrawn event. The event signature is unchanged, the decoded fields are unchanged, and nothing reverts. Solidity's docs describe exactly what is lost: indexed parameters go into "topics" instead of the data part of the log, and "topics allow you to search for events, for example when filtering a sequence of blocks".

All twelve Solidity core tests passed. All twelve TypeScript core tests passed. So did the fuzz test and the invariant. And so did the TypeScript test that filters logs by account through the contract's own ABI, which is the one I had written expecting to fail.

I probed why, running the same three queries against both versions of the contract:

indexed Topics per log viem args filter Raw eth_getLogs topic filter
Original yes 2 1 log 1 log
Mutated no 1 1 log 0 logs

viem 2.56.3 builds the eth_getLogs topics only from parameters the ABI marks as indexed, then filters whatever comes back by comparing decoded arguments in memory. Hand it the contract's current ABI and args: { account: alice } keeps working, silently, at the cost of fetching every log and filtering client-side. A raw topic filter — which is what a subgraph, an indexer, or any eth_getLogs pipeline uses — returns nothing at all.

Two tests out of thirty-two caught it, one in each language, and they are the same idea written twice. In Solidity, the one that declares its own copy of the event instead of importing the contract's:

event Withdrawn(address indexed account, uint256 shares, uint256 assets);

With a local declaration, vm.expectEmit compares topics against the layout the test asserts rather than the layout the contract currently has. In TypeScript, the one that parses the event signature itself instead of passing vault.abi:

const WITHDRAWN = parseAbiItem(
  "event Withdrawn(address indexed account, uint256 shares, uint256 assets)",
);

Because that ABI item says indexed, viem builds the topic, the node filters on it, and the query comes back empty — expected exactly one withdrawal for alice, received 0.

Same pattern as the domain separator, in a different place, and this time both languages demonstrate it. The tests that noticed were the ones whose expectation did not come from the code under test.

How I would split them

The measured answer is not "Solidity for units, TypeScript for integration", even though that is roughly where I land. It is that three separate decisions hide behind one question, and the language is the least consequential of them.

Write in Solidity when the property is internal to the contract. Arithmetic, rounding, storage transitions, revert conditions, and anything you want to fuzz. The runner is 2.1 times as fast in the same project and faster still as forge test, you can reach internal functions through a test harness contract, and fuzzing and invariants are a keyword rather than a project.

Write in TypeScript when the test's job is to prove that something outside the contract agrees with it. Signed messages, log filtering, ABI decoding, multi-transaction flows, and anything a frontend or a bot will do. Hardhat's own framing — tests that "mirror consumers" — is the right one, and it is the case my mutations backed up.

Then, in either language, ask where each expectation comes from. This is the decision that determined three of my six results, and the only one I could flip on demand: writing the domain separator out by hand made a Solidity test catch what the Solidity suite missed, and parsing the event signature by hand made a TypeScript test catch what the TypeScript suite missed. If the expected value is read back from the contract under test, the test is checking self-consistency, and it will keep passing through the exact class of change that breaks your users. Write the hash, the topic layout, the domain, and the encoding down independently, and check the contract against that.

If your project is Foundry-only, which the Solidity Developer Survey 2025 suggests is the common case at 57 percent primary usage against Hardhat's 33 percent combined, you do not have a second runner and the third decision is the whole of your leverage. That is worth knowing before concluding that a Solidity-only suite covers the client boundary. Hardhat's rebuild is what makes having both cheap; it does not make either one sufficient.

What this does not settle

Six mutations on two small contracts is not a mutation-testing campaign, and I chose the defects. A different six would produce a different table, and I picked ones that separate the categories I wanted to examine rather than a random sample of real bugs.

The timings are one synthetic workload on one machine. Twelve cases is small enough that process startup is a visible share of every number, which is exactly why I reported the whole command rather than in-process execution; it is also why the ratio should not be extrapolated to a large suite without measuring it.

The comparison also holds the client library fixed. I used viem, and viem's in-memory argument filtering is the specific behaviour that let the indexing defect through the ABI-driven query. Ethers.js is still the more widely used library — 70 percent of survey respondents against viem's 39 — and it may behave differently here.

Finally, none of this measures the thing that decides most suites in practice, which is whether the tests get written at all. A team fluent in TypeScript that will actually maintain a TypeScript suite is better off than the same team writing three neglected Solidity tests because a benchmark said Solidity was 600 milliseconds faster.