Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fuzz): ability to declare fuzz test fixtures #7428

Merged
merged 31 commits into from
Apr 22, 2024

Conversation

grandizzy
Copy link
Collaborator

@grandizzy grandizzy commented Mar 18, 2024

Motivation

Closes #3521
Proposed implementation for #735 (review) with fixtures declared in Solidity tests

As mentioned in #3521 the number of fuzzed edge cases is too high (only 29% unique), this is due to:

  • int and uint strategies fall back to generating edge cases in case there's no fixture to use, see
    fn generate_fixtures_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
    // generate edge cases if there's no fixtures
    if self.fixtures.is_empty() {
    return self.generate_edge_tree(runner)
    }

    fn generate_fixtures_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
    // generate edge cases if there's no fixtures
    if self.fixtures.is_empty() {
    return self.generate_edge_tree(runner)
    }
  • defining fixtures is not supported (initialized as vec![]) so this gives a much higher weight for generating edge cases, see
    DynSolType::Int(n @ 8..=256) => {
    super::IntStrategy::new(n, vec![]).prop_map(move |x| DynSolValue::Int(x, n)).boxed()
    }

    DynSolType::Uint(n @ 8..=256) => {
    super::UintStrategy::new(n, vec![]).prop_map(move |x| DynSolValue::Uint(x, n)).boxed()
    }

Solution

Proposed solution:

Fixtures can be defined as array of values to be used in fuzzed tests (support for address, address, uint, int and bytes types) by

  • declaring a function prefixed with fixture and followed by param name to be fuzzed. Function should return an (fixed size or dynamic) array of values to be used for fuzzing. For example, fixtures to be used when fuzzing parameter named owner of type address can be defined in Solidity test as
function fixtureOwner() public returns (address[] memory)
  • declaring a storage array prefixed with fixture and followed by param name to be fuzzed. For example, fixtures to be used when fuzzing parameter named amount of type uint32 can be defined as
uint32[] public fixtureAmount = [1122334455, 1, 555];
  • test fixtures can be defined as both storage arrays and functions at the same time
  • if the return type is not an array or is not same type as the named parameter type then an error is raised and fuzzed is performed with random values

Fixtures with setUp sample:

  • To define fixtures for owner fuzzed parameter of type address
address[] ownerFixture;

function setUp() public {
    ownerFixture.push(address(1));
    ownerFixture.push(address(2));
    ownerFixture.push(address(3));
}

function fixtureOwner() public returns (address[] memory) {
    return ownerFixture;
}
  • write fuzzed tests by using owner param name so all defined fixtures are used when fuzzing
function testFuzz_Address(address owner) public {
}
  • if a function returning different fixture type is declared, e.g.
function fixtureOwner() public returns (uint[] memory) {
}

then fuzzer will panic

Fixtures without setUp sample:

int256[] nosetupFixture;

function fixture_nosetup() public returns (int256[] memory) {
    int256[] memory nosetupFixture = new int256[](2);
    nosetupFixture[0] = -78787;
    nosetupFixture[1] = 77777;
    return nosetupFixture;
}

function testFuzz_NoSetup(int256 nosetup) public {
}

Fixtures from storage arrays:

contract FuzzTest is Test {
    bytes32[] public fixtureKey = [bytes32("abcd1234")];

    function testFuzzKey(bytes32 _key) public {
    }
}

Fixtures as fixed sized arrays:

 function fixtureMagic() external returns (int32[3] memory) {
    int32[3] memory magic;
    magic[0] = 1;
    magic[1] = -777;
    magic[2] = -2;
    return magic;
}

Some numbers collected from running without and with fixtures for a uint fuzzed param:

Run (with fixtures) edge random fixtures
1 11.4% 45.2% 43.24%
2 12.5% 51.19% 36.3%
3 9.86% 52.63% 37.5%
4 12.92% 43.53% 43.53%
Run (no fixtures) edge random
1 13.7% 86.3%
2 9.5% 90.5%
3 8.2% 91.8%
4 8.7% 91.3%

@grandizzy grandizzy changed the title [WIP] fix(fuzz): deduplicate fuzz inputs [WIP] feat(fuzz): ability to declare fuzz test fixtures Mar 19, 2024
@grandizzy grandizzy force-pushed the issue-3521-fixtures branch 2 times, most recently from 12c4e0c to 5b92ee1 Compare March 19, 2024 12:07
@grandizzy grandizzy changed the title [WIP] feat(fuzz): ability to declare fuzz test fixtures feat(fuzz): ability to declare fuzz test fixtures Mar 20, 2024
@grandizzy grandizzy marked this pull request as ready for review March 20, 2024 10:33
Copy link
Member

@mattsse mattsse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smol nits,
ptal @DaniPopes @klkvr

Self { inner: Arc::new(fixtures) }
}

pub fn param_fixtures(&self, param_name: &String) -> Option<&[DynSolValue]> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs oneline doc

/// `function fixture_owner() public returns (address[] memory)`.
/// Use `owner` named parameter in fuzzed test in order to create a custom strategy
/// `function testFuzz_ownerAddress(address owner, uint amount)`.
#[derive(Debug)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[derive(Debug)]
#[derive(Debug, Default)]
#[non_exhaustive]

@@ -221,7 +221,7 @@ impl TestArgs {
*test_pattern = Some(debug_test_pattern.clone());
}

let outcome = self.run_tests(runner, config, verbosity, &filter).await?;
let outcome = self.run_tests(runner, config.clone(), verbosity, &filter).await?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These clones are not necessary

@@ -181,7 +181,7 @@ impl TestArgs {

let test_options: TestOptions = TestOptionsBuilder::default()
.fuzz(config.clone().fuzz)
.invariant(config.invariant)
.invariant(config.clone().invariant)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move clone after .invariant to clone only that object instead of the entire config

same above for fuzz

fn fuzz_fixtures(&mut self, address: Address) -> FuzzFixtures {
// collect test fixtures param:array of values
let mut fixtures = HashMap::new();
self.contract.functions().for_each(|func| {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use a for loop with .filter(|f| f.name.is_fixture())

self.executor.call(CALLER, address, func, &[], U256::ZERO, None)
{
fixtures.insert(
func.name.strip_prefix("fixture_").unwrap().to_string(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should check if there's a function with the stripped name and issue a warning

{
fixtures.insert(
func.name.strip_prefix("fixture_").unwrap().to_string(),
decoded_result,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should check for validity of the decoded result here instead of later when running the tests since all necessary info to do this is available here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would be nice to have but needs special handling of handler based testing (where fuzzed functions are not in same contract that defines setUp/invariant_/fixture_ functions). It can be done but it adds code complexity to look up such info in all contracts created when setUp, wdyt?

@grandizzy
Copy link
Collaborator Author

grandizzy commented Mar 21, 2024

I addressed comments (but #7428 (comment)) and additionally moved fuzz_calldata_from_state from state mod in same calldata mod as fuzz_calldata, think it makes more sense to have grouped them like this (more code refactoring could be done as these functions are similar). Please recheck

@grandizzy
Copy link
Collaborator Author

hey @mattsse @DaniPopes I added fixtures for string and bytes too and a Solidity test to cover all combinations, pls have a look when you get time. thank you

@@ -202,7 +202,7 @@ impl TestArgs {
let runner = MultiContractRunnerBuilder::default()
.set_debug(should_debug)
.initial_balance(evm_opts.initial_balance)
.evm_spec(config.evm_spec_id())
.evm_spec(config.clone().evm_spec_id())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this clone

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, missed this comment, removed with a5e8da0

@grandizzy grandizzy changed the title feat(fuzz): ability to declare fuzz test fixtures WIP feat(fuzz): ability to declare fuzz test fixtures Apr 11, 2024
@grandizzy grandizzy marked this pull request as draft April 11, 2024 05:15
@grandizzy
Copy link
Collaborator Author

sorry, just got a chance to read through, looking great! a couple questions
unfortunately Solidity does not offer great UX for dealing with arrays, so I think we should also consider adding support for reading fixtures from storage arrays:

contract Test {
    uint256[] public fixureName = [1, 2, 3];
}

vs

contract Test {
    function fixureName() public view returns(uint256[] memory vals) {
        vals = new uint256[](3);
        vals[0] = 1;
        vals[1] = 2;
        vals[2] = 3;
    }
}

yep, good call, I think this way we could even drop the inline config and do the best effort to match / map fixtures from functions within test contract, will look into adding such

@klkvr I added support for reading fixtures from storage and dropped the inline fixtures config with fb86084 Didn't find a nicer way to populate fixtures from storage array but to call function with incremented indexes, pls lmk if you see a nicer way for doing this. see
fb86084#diff-98c09a5d1cb595f37109d7b1c0109ee32dd74dc1d253cdda9a63e4ce32bf49d5R227-R251

@grandizzy grandizzy marked this pull request as ready for review April 11, 2024 17:04
@grandizzy grandizzy changed the title WIP feat(fuzz): ability to declare fuzz test fixtures feat(fuzz): ability to declare fuzz test fixtures Apr 11, 2024
Copy link
Member

@klkvr klkvr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@grandizzy we shouldn't just panic on user errors, I meant that we should also have validation somewhere which will throw a error, thus making panic more safe

we can probably either do another pass through ABIs before running tests to check if there are any mismatches between fixture types and parameter types or validate this when generating strategies.

/// `function fixture_owner() public returns (address[] memory){}`
/// returns an array of addresses to be used for fuzzing `owner` named parameter in scope of the
/// current test.
fn fuzz_fixtures(&mut self, address: Address) -> FuzzFixtures {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just have this check here

@grandizzy
Copy link
Collaborator Author

@grandizzy we shouldn't just panic on user errors, I meant that we should also have validation somewhere which will throw a error, thus making panic more safe

we can probably either do another pass through ABIs before running tests to check if there are any mismatches between fixture types and parameter types or validate this when generating strategies.

@klkvr if I am not missing something a fully validation when collecting fixtures from invariant contract isn't possible as there could be targets created during runs that we don't have at the moment test is set up. Even if we manage to do the validation then the option would be to panic if params not of same types (otherwise it will still panic later in the run). If my rationale is correct what do you think if we just use some default values in case fixture of different type (like 0 for uint, adrress(0), empty string, etc.) and raise errors instead panic, they are quite noisy in the console and hard to be missed, so dev will know fixtures needs to be amended. thanks!

@klkvr
Copy link
Member

klkvr commented Apr 17, 2024

@klkvr if I am not missing something a fully validation when collecting fixtures from invariant contract isn't possible as there could be targets created during runs that we don't have at the moment test is set up

hmm, I see. what if we do prop_filter_map and reject such values thus forcing fallback to default strategy? also display a error/warning to user

@grandizzy
Copy link
Collaborator Author

@klkvr if I am not missing something a fully validation when collecting fixtures from invariant contract isn't possible as there could be targets created during runs that we don't have at the moment test is set up

hmm, I see. what if we do prop_filter_map and reject such values thus forcing fallback to default strategy? also display a error/warning to user

oh, awesome stuff, this should handle all cases, added in 997c5d4

@grandizzy grandizzy requested a review from klkvr April 18, 2024 07:14
Comment on lines +26 to +38
proptest::prop_oneof![
50 => {
let custom_fixtures: Vec<DynSolValue> =
fixtures.iter().enumerate().map(|(_, value)| value.to_owned()).collect();
let custom_fixtures_len = custom_fixtures.len();
any::<prop::sample::Index>()
.prop_filter_map("invalid fixture", move |index| {
let index = index.index(custom_fixtures_len);
$strategy_value(custom_fixtures.get(index))
})
},
50 => $default_strategy
].boxed()
Copy link
Member

@klkvr klkvr Apr 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we apply prop_filter_map to the entire prop_oneof! output? I believe right now if we only have invalid fixtures it will fail with max rejects while it'd be better if it used default strategy instead

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was thinking that could be better to fail (faster) with max rejects rather than run tests with a config that is not really desired, but can change to apply to both strategies, wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, let's keep it like this for now

Copy link
Member

@klkvr klkvr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! nice work

@mattsse @DaniPopes mind taking a look?

Copy link
Member

@mattsse mattsse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

@mattsse mattsse merged commit 008922d into foundry-rs:master Apr 22, 2024
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

feat: deduplicate fuzz inputs
5 participants