Remove quickcheck in favour of proptest (#8471)

Consolidate our property-testing around `proptest`. This PR was written with Copilot and manually tweaked.


Co-Authored-By: Michael Sproul <michael@sproul.xyz>

Co-Authored-By: Michael Sproul <michael@sigmaprime.io>
This commit is contained in:
Michael Sproul
2025-11-27 16:53:55 +11:00
committed by GitHub
parent 4494b0a684
commit 070e395714
6 changed files with 244 additions and 219 deletions

38
Cargo.lock generated
View File

@@ -3084,16 +3084,6 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "env_logger"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3"
dependencies = [
"log",
"regex",
]
[[package]] [[package]]
name = "environment" name = "environment"
version = "0.1.2" version = "0.1.2"
@@ -5525,8 +5515,7 @@ dependencies = [
"network_utils", "network_utils",
"parking_lot", "parking_lot",
"prometheus-client", "prometheus-client",
"quickcheck", "proptest",
"quickcheck_macros",
"rand 0.9.2", "rand 0.9.2",
"regex", "regex",
"serde", "serde",
@@ -5833,8 +5822,7 @@ dependencies = [
"alloy-primitives", "alloy-primitives",
"ethereum_hashing", "ethereum_hashing",
"fixed_bytes", "fixed_bytes",
"quickcheck", "proptest",
"quickcheck_macros",
"safe_arith", "safe_arith",
] ]
@@ -7222,28 +7210,6 @@ dependencies = [
"unsigned-varint 0.8.0", "unsigned-varint 0.8.0",
] ]
[[package]]
name = "quickcheck"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6"
dependencies = [
"env_logger",
"log",
"rand 0.8.5",
]
[[package]]
name = "quickcheck_macros"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f71ee38b42f8459a88d3362be6f9b841ad2d5421844f61eb1c59c11bff3ac14a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"

View File

@@ -201,9 +201,8 @@ parking_lot = "0.12"
paste = "1" paste = "1"
pretty_reqwest_error = { path = "common/pretty_reqwest_error" } pretty_reqwest_error = { path = "common/pretty_reqwest_error" }
prometheus = { version = "0.13", default-features = false } prometheus = { version = "0.13", default-features = false }
proptest = "1"
proto_array = { path = "consensus/proto_array" } proto_array = { path = "consensus/proto_array" }
quickcheck = "1"
quickcheck_macros = "1"
quote = "1" quote = "1"
r2d2 = "0.8" r2d2 = "0.8"
rand = "0.9.0" rand = "0.9.0"

View File

@@ -72,6 +72,5 @@ features = [
[dev-dependencies] [dev-dependencies]
async-channel = { workspace = true } async-channel = { workspace = true }
logging = { workspace = true } logging = { workspace = true }
quickcheck = { workspace = true } proptest = { workspace = true }
quickcheck_macros = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }

View File

@@ -2975,8 +2975,7 @@ mod tests {
use crate::peer_manager::tests::build_peer_manager_with_trusted_peers; use crate::peer_manager::tests::build_peer_manager_with_trusted_peers;
use crate::rpc::{MetaData, MetaDataV3}; use crate::rpc::{MetaData, MetaDataV3};
use libp2p::PeerId; use libp2p::PeerId;
use quickcheck::{Arbitrary, Gen, TestResult}; use proptest::prelude::*;
use quickcheck_macros::quickcheck;
use std::collections::HashSet; use std::collections::HashSet;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use types::{DataColumnSubnetId, Unsigned}; use types::{DataColumnSubnetId, Unsigned};
@@ -2994,69 +2993,111 @@ mod tests {
custody_subnets: HashSet<DataColumnSubnetId>, custody_subnets: HashSet<DataColumnSubnetId>,
} }
impl Arbitrary for PeerCondition { fn peer_condition_strategy() -> impl Strategy<Value = PeerCondition> {
fn arbitrary(g: &mut Gen) -> Self { let attestation_len = <E as EthSpec>::SubnetBitfieldLength::to_usize();
let attestation_net_bitfield = { let sync_committee_len = <E as EthSpec>::SyncCommitteeSubnetCount::to_usize();
let len = <E as EthSpec>::SubnetBitfieldLength::to_usize();
let mut bitfield = Vec::with_capacity(len);
for _ in 0..len {
bitfield.push(bool::arbitrary(g));
}
bitfield
};
let sync_committee_net_bitfield = {
let len = <E as EthSpec>::SyncCommitteeSubnetCount::to_usize();
let mut bitfield = Vec::with_capacity(len);
for _ in 0..len {
bitfield.push(bool::arbitrary(g));
}
bitfield
};
let spec = E::default_spec(); let spec = E::default_spec();
let custody_subnets = {
let total_subnet_count = spec.data_column_sidecar_subnet_count; let total_subnet_count = spec.data_column_sidecar_subnet_count;
let custody_subnet_count = u64::arbitrary(g) % (total_subnet_count + 1); // 0 to 128 let custody_requirement = spec.custody_requirement;
(spec.custody_requirement..total_subnet_count)
.filter(|_| bool::arbitrary(g)) // Create the pool of available subnet IDs
let available_subnets: Vec<u64> = (custody_requirement..total_subnet_count).collect();
let max_custody_subnets = available_subnets.len();
// Trusted peer probability constants - 1 in 5 peers should be trusted (20%)
const TRUSTED_PEER_WEIGHT_FALSE: u32 = 4;
const TRUSTED_PEER_WEIGHT_TRUE: u32 = 1;
(
proptest::collection::vec(any::<bool>(), attestation_len),
proptest::collection::vec(any::<bool>(), sync_committee_len),
any::<f64>(),
any::<bool>(),
any::<f64>(),
// Weight trusted peers to avoid test rejection due to too many trusted peers
prop_oneof![
TRUSTED_PEER_WEIGHT_FALSE => Just(false),
TRUSTED_PEER_WEIGHT_TRUE => Just(true),
],
0..=max_custody_subnets,
)
.prop_flat_map(
move |(
attestation_net_bitfield,
sync_committee_net_bitfield,
score,
outgoing,
gossipsub_score,
trusted,
custody_subnet_count,
)| {
// Use proptest's subsequence to select a random subset of subnets
let custody_subnets_strategy = proptest::sample::subsequence(
available_subnets.clone(),
custody_subnet_count,
);
(
Just(attestation_net_bitfield),
Just(sync_committee_net_bitfield),
Just(score),
Just(outgoing),
Just(gossipsub_score),
Just(trusted),
custody_subnets_strategy,
)
},
)
.prop_map(
|(
attestation_net_bitfield,
sync_committee_net_bitfield,
score,
outgoing,
gossipsub_score,
trusted,
custody_subnets_vec,
)| {
let custody_subnets: HashSet<DataColumnSubnetId> = custody_subnets_vec
.into_iter()
.map(DataColumnSubnetId::new) .map(DataColumnSubnetId::new)
.take(custody_subnet_count as usize) .collect();
.collect()
};
PeerCondition { PeerCondition {
peer_id: PeerId::random(), peer_id: PeerId::random(),
outgoing: bool::arbitrary(g), outgoing,
attestation_net_bitfield, attestation_net_bitfield,
sync_committee_net_bitfield, sync_committee_net_bitfield,
score: f64::arbitrary(g), score,
trusted: bool::arbitrary(g), trusted,
gossipsub_score: f64::arbitrary(g), gossipsub_score,
custody_subnets, custody_subnets,
} }
} },
)
} }
#[quickcheck] // Upper bound for testing peer pruning - we test with at least the target number
fn prune_excess_peers(peer_conditions: Vec<PeerCondition>) -> TestResult { // and up to 50% more than the target to verify pruning behavior.
const MAX_TEST_PEERS: usize = 300;
proptest! {
#[test]
fn prune_excess_peers(peer_conditions in proptest::collection::vec(peer_condition_strategy(), DEFAULT_TARGET_PEERS..=MAX_TEST_PEERS)) {
let target_peer_count = DEFAULT_TARGET_PEERS; let target_peer_count = DEFAULT_TARGET_PEERS;
let spec = E::default_spec(); let spec = E::default_spec();
if peer_conditions.len() < target_peer_count {
return TestResult::discard();
}
let trusted_peers: Vec<_> = peer_conditions let trusted_peers: Vec<_> = peer_conditions
.iter() .iter()
.filter_map(|p| if p.trusted { Some(p.peer_id) } else { None }) .filter_map(|p| if p.trusted { Some(p.peer_id) } else { None })
.collect(); .collect();
// If we have a high percentage of trusted peers, it is very difficult to reason about // If we have a high percentage of trusted peers, it is very difficult to reason about
// the expected results of the pruning. // the expected results of the pruning.
if trusted_peers.len() > peer_conditions.len() / 3_usize { prop_assume!(trusted_peers.len() <= peer_conditions.len() / 3_usize);
return TestResult::discard();
}
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(async move { let result = rt.block_on(async move {
// Collect all the trusted peers // Collect all the trusted peers
let mut peer_manager = let mut peer_manager =
build_peer_manager_with_trusted_peers(trusted_peers, target_peer_count).await; build_peer_manager_with_trusted_peers(trusted_peers, target_peer_count).await;
@@ -3143,10 +3184,11 @@ mod tests {
.is_connected(&condition.peer_id) .is_connected(&condition.peer_id)
}); });
TestResult::from_bool( (target_peer_condition || hit_outbound_limit) && !trusted_peer_disconnected
(target_peer_condition || hit_outbound_limit) && !trusted_peer_disconnected, });
)
}) prop_assert!(result);
}
} }
} }

View File

@@ -14,5 +14,4 @@ fixed_bytes = { workspace = true }
safe_arith = { workspace = true } safe_arith = { workspace = true }
[dev-dependencies] [dev-dependencies]
quickcheck = { workspace = true } proptest = { workspace = true }
quickcheck_macros = { workspace = true }

View File

@@ -413,18 +413,41 @@ impl From<InvalidSnapshot> for MerkleTreeError {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use quickcheck::TestResult;
use quickcheck_macros::quickcheck;
use proptest::prelude::*;
// Limit test depth to avoid generating huge trees. Depth 10 = 1024 max leaves.
const TEST_MAX_DEPTH: usize = 10;
fn merkle_leaves_strategy(max_depth: usize) -> impl Strategy<Value = (Vec<u64>, usize)> {
(0..=max_depth).prop_flat_map(|depth| {
let max_leaves = 2usize.pow(depth as u32);
(
proptest::collection::vec(any::<u64>(), 0..=max_leaves),
Just(depth),
)
})
}
fn merkle_leaves_strategy_min_depth(
max_depth: usize,
min_depth: usize,
) -> impl Strategy<Value = (Vec<u64>, usize)> {
(min_depth..=max_depth).prop_flat_map(|depth| {
let max_leaves = 2usize.pow(depth as u32);
(
proptest::collection::vec(any::<u64>(), 0..=max_leaves),
Just(depth),
)
})
}
proptest::proptest! {
/// Check that we can: /// Check that we can:
/// 1. Build a MerkleTree from arbitrary leaves and an arbitrary depth. /// 1. Build a MerkleTree from arbitrary leaves and an arbitrary depth.
/// 2. Generate valid proofs for all of the leaves of this MerkleTree. /// 2. Generate valid proofs for all of the leaves of this MerkleTree.
#[quickcheck] #[test]
fn quickcheck_create_and_verify(int_leaves: Vec<u64>, depth: usize) -> TestResult { fn proptest_create_and_verify((int_leaves, depth) in merkle_leaves_strategy(TEST_MAX_DEPTH)) {
if depth > MAX_TREE_DEPTH || int_leaves.len() > 2usize.pow(depth as u32) {
return TestResult::discard();
}
let leaves: Vec<_> = int_leaves.into_iter().map(H256::from_low_u64_be).collect(); let leaves: Vec<_> = int_leaves.into_iter().map(H256::from_low_u64_be).collect();
let merkle_tree = MerkleTree::create(&leaves, depth); let merkle_tree = MerkleTree::create(&leaves, depth);
let merkle_root = merkle_tree.hash(); let merkle_root = merkle_tree.hash();
@@ -436,15 +459,11 @@ mod tests {
leaf == leaves[i] && verify_merkle_proof(leaf, &branch, depth, i, merkle_root) leaf == leaves[i] && verify_merkle_proof(leaf, &branch, depth, i, merkle_root)
}); });
TestResult::from_bool(proofs_ok) proptest::prop_assert!(proofs_ok);
}
#[quickcheck]
fn quickcheck_push_leaf_and_verify(int_leaves: Vec<u64>, depth: usize) -> TestResult {
if depth == 0 || depth > MAX_TREE_DEPTH || int_leaves.len() > 2usize.pow(depth as u32) {
return TestResult::discard();
} }
#[test]
fn proptest_push_leaf_and_verify((int_leaves, depth) in merkle_leaves_strategy_min_depth(TEST_MAX_DEPTH, 1)) {
let leaves_iter = int_leaves.into_iter().map(H256::from_low_u64_be); let leaves_iter = int_leaves.into_iter().map(H256::from_low_u64_be);
let mut merkle_tree = MerkleTree::create(&[], depth); let mut merkle_tree = MerkleTree::create(&[], depth);
@@ -456,7 +475,8 @@ mod tests {
stored_leaf == leaf && verify_merkle_proof(leaf, &branch, depth, i, merkle_tree.hash()) stored_leaf == leaf && verify_merkle_proof(leaf, &branch, depth, i, merkle_tree.hash())
}); });
TestResult::from_bool(proofs_ok) proptest::prop_assert!(proofs_ok);
}
} }
#[test] #[test]