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: unit test external call mock #14

Open
wants to merge 37 commits into
base: main
Choose a base branch
from

Conversation

qalisander
Copy link
Member

@qalisander qalisander commented Dec 19, 2024

Based on this pr (OpenZeppelin/rust-contracts-stylus#423)

Resolves #8 , Resolves #12

PR Checklist

  • Tests
  • Documentation
  • Changelog

@qalisander qalisander changed the title feat(test): unit test external call mock POC feat: unit test external call mock POC Dec 19, 2024
@qalisander qalisander changed the title feat: unit test external call mock POC feat: unit test external call mock Dec 19, 2024
@qalisander qalisander force-pushed the unit-tests/multiple-contract-deployment branch from 3838020 to feb635a Compare December 23, 2024 18:48
…ontract-deployment

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/motsu/README.md
@qalisander
Copy link
Member Author

Update documentation for the new test case structure

@qalisander qalisander marked this pull request as ready for review January 15, 2025 12:16
Copy link
Collaborator

@0xNeshi 0xNeshi left a comment

Choose a reason for hiding this comment

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

This is my first pass, really good work! 💪

@@ -15,57 +15,45 @@ pub(crate) fn test(_attr: &TokenStream, input: TokenStream) -> TokenStream {
let fn_block = &item_fn.block;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I left my feedback on motsu::test macro in our group chat, but the gist of it is that with the new context design in this PR, contracts/EOAs can be safely manually instantiated with little effort, and fuzzing crates like proptest work with motsu out-of-the-box (no need for special macro wrappers).

However, there are arguments to be made that we need to keep this attribute:

  1. Backward compatibility.
  2. Enables us to easily add additional features in the future.
  3. A/B testing - we document ways for devs to instantiate contracts/EOAs (injected as is now, and manual), and after some time we investigate how the devs actually prefer to use the crate - if either of the approaches gains drastically more adoption, we can always drop the other.

crates/motsu/src/context.rs Show resolved Hide resolved
crates/motsu/src/context.rs Show resolved Hide resolved
crates/motsu/src/context.rs Outdated Show resolved Hide resolved
crates/motsu/src/shims.rs Outdated Show resolved Hide resolved
@0xNeshi 0xNeshi mentioned this pull request Jan 17, 2025
3 tasks
Copy link
Collaborator

@bidzyyys bidzyyys left a comment

Choose a reason for hiding this comment

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

I like the design, left some minor comments.

crates/motsu/src/context.rs Outdated Show resolved Hide resolved
crates/motsu/src/context.rs Outdated Show resolved Hide resolved
crates/motsu/src/context.rs Outdated Show resolved Hide resolved
crates/motsu/src/context.rs Show resolved Hide resolved
crates/motsu/src/router.rs Outdated Show resolved Hide resolved
crates/motsu/src/router.rs Outdated Show resolved Hide resolved
@bidzyyys bidzyyys mentioned this pull request Jan 21, 2025
3 tasks
Copy link
Collaborator

@0xNeshi 0xNeshi left a comment

Choose a reason for hiding this comment

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

I'm loving this refactor, it really seems to set us up for easier improvements later

crates/motsu/src/lib.rs Outdated Show resolved Hide resolved
crates/motsu/src/lib.rs Outdated Show resolved Hide resolved
#[derive(Default)]
struct MockStorage {
/// Address of the message sender.
msg_sender: Option<Address>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is msg_sender ever not set? Same for contract_address.

Copy link
Member Author

@qalisander qalisander Jan 23, 2025

Choose a reason for hiding this comment

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

Yes. It is not set, once the test context initialized. e.g. when for the first contract called Contract::<_>::new() and the msg_sender is not defined still.
It is usually bad practice in rust: use default type value as a special variant. Option is more safe.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree with @qalisander, having defaults may introduce some weird issues with writing unit tests (we may know but other people using our motsu). Having an Option enforces devs to initialize the context properly.

Copy link
Collaborator

@0xNeshi 0xNeshi Jan 23, 2025

Choose a reason for hiding this comment

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

The drawback with this approach is that it forces the developer to set .sender(some_account) at least once, is it not?

Solidity's and Cairo's Foundry have a default msg sender set for each test case (a predefined address), I think it would be ergonomic to do the same in motsu. Otherwise, we force the dev to create a new account for each test case, and set .sender(addr) even when mocking msg::sender is not required.

Copy link
Member Author

Choose a reason for hiding this comment

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

I believe the current approach adds more safety, since neither contract's function would be called without sender explicitly specified.

Copy link
Member Author

@qalisander qalisander Jan 23, 2025

Choose a reason for hiding this comment

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

So, by current design you cannot call any function on account before calling .sender(address). If it would be possible, some test scenarios would be a bit obscure for devs, who hasn't explored documentation carefully. And it is easy to forget to indicate a sender for contract, where it makes sense.

Ofc for some developers DevX can suffer, but they still can write their tests in a way:

let contract = contract.sender(alice);
contract.mint(...);
contract.transfer(...);
contract.burn(...);

And don't repeat .sender(..) multiple times if contract should be called from the same address always.

p.s. Consider, that we also had sender address hard-coded before, but we couldn't do it otherwise. I'm not sure about exact reason, why foundry doing so. But may be it's just a legacy and not devx.

Copy link
Member Author

@qalisander qalisander Jan 23, 2025

Choose a reason for hiding this comment

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

And we can update docs and readme, how is it possible to structure unit tests from the now on

Copy link
Collaborator

@0xNeshi 0xNeshi Jan 23, 2025

Choose a reason for hiding this comment

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

If it would be possible, some test scenarios would be a bit obscure for devs...

I can't say I agree on this point, I think a test that "just works" even without an explicit sender is better & clearer.

-> don't care about sender -> sender not set -> test fails <-- this is obscure behavior imo
-> need specific sender -> sender not set -> test fails <-- this is perfectly expected behavior

Which ties into the following...

And it is easy to forget to indicate a sender for contract, where it makes sense.

It will be even easier to forget to set a sender when you don't need it, which in my experience is majority of cases.

In addition to that, if a dev knows a specific sender is necessary to send a transaction in their test, I think it's safe to say they'll remember to set it.

...who hasn't explored documentation carefully.

This is an argument for having a default address - devs who don't read docs won't know that they need to set the sender manually for a contract. They will find their test failing, and have to debug/read the docs to figure out that we don't set msg::sender by default.

P.S. Devs who don't read docs will always have problems, no matter how thought-out your tool design.

Copy link
Member Author

@qalisander qalisander Jan 23, 2025

Choose a reason for hiding this comment

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

-> don't care about sender -> sender not set -> test fails <-- this is obscure behavior imo

Yes, obscure. That is why our behavior is: don't care about sender -> test will not compile. We rely more on the type system.

As I see we disagree, so make sense to not release motsu by EOW and discuss it later.

Copy link
Collaborator

@0xNeshi 0xNeshi Jan 23, 2025

Choose a reason for hiding this comment

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

That is why our behavior is: don't care about sender -> test will not compile. We rely more on the type system.

Ah you're right, because Contract has to first return a ContractCall to be able to call its functions. That means there would be no runtime surprises, so we can discard that part of my argument.

I'm still not sure I understand the safety argument for not having a default sender, but I see a couple of benefits if we had it:

  • DevX
  • ContractCall can be merged into Contract (one less struct to worry about)

As I see we disagree, so make sense to not release motsu by EOW and discuss it later.

This isn't a hill I'm willing to die on. I was more looking to understand the safety that comes from requiring that devs set sender explicitly. I think we got sidetracked by the "ergonomics + documentation" discussion, but my original desire was to understand the safety aspect.

If both of you still think this is the way to go, then I'm behind you! No need to hold this up

crates/motsu/src/context.rs Show resolved Hide resolved
crates/motsu/src/shims.rs Show resolved Hide resolved
Copy link
Collaborator

@bidzyyys bidzyyys left a comment

Choose a reason for hiding this comment

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

Can't wait to have it published on crates.io 🚀
Great job @qalisander!

#[derive(Default)]
struct MockStorage {
/// Address of the message sender.
msg_sender: Option<Address>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree with @qalisander, having defaults may introduce some weird issues with writing unit tests (we may know but other people using our motsu). Having an Option enforces devs to initialize the context properly.

crates/motsu/src/context.rs Show resolved Hide resolved
@@ -112,24 +112,23 @@ pub unsafe extern "C" fn storage_cache_bytes32(
/// of [`SSTORE`].
///
/// [`SSTORE`]: https://www.evm.codes/#55
pub fn storage_flush_cache(_: bool) {
#[no_mangle]
pub unsafe extern "C" fn storage_flush_cache(_: bool) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need unsafe here? It does not perform any action.

crates/motsu/src/shims.rs Show resolved Hide resolved
.ping(pong.address(), value)
.expect("should ping successfully");

assert_eq!(ponged_value, value + uint!(1_U256));
Copy link
Collaborator

Choose a reason for hiding this comment

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

How about having:
let one = uint!(1_U256);

You have many repetitions.

crates/motsu/src/lib.rs Show resolved Hide resolved
Comment on lines +27 to 33
//! fn reads_balance(
//! contract: Contract<Erc20>,
//! alice: Account,
//! ) {
//! let balance = contract.sender(alice).balance_of(Address::ZERO); // Access storage.
//! assert_eq!(balance, U256::ZERO);
//! }
Copy link
Collaborator

@0xNeshi 0xNeshi Jan 23, 2025

Choose a reason for hiding this comment

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

Suggested change
//! fn reads_balance(
//! contract: Contract<Erc20>,
//! alice: Account,
//! ) {
//! let balance = contract.sender(alice).balance_of(Address::ZERO); // Access storage.
//! assert_eq!(balance, U256::ZERO);
//! }
//! fn reads_balance(
//! contract: Contract<Erc20>,
//! alice: Account,
//! ) {
//! let balance = contract.sender(alice).balance_of(Address::ZERO); // Access storage.
//! assert_eq!(balance, U256::ZERO);
//! }
//!
//! #[motsu::test]
//! fn approve() {
//! let contract = Contract::<Erc20>::new();
//! let alice = Account::random();
//! let bob = Account::random();
//! let result = contract.sender(alice).approve(bob, U256::from(1)); // Change state.
//! assert!(result.is_ok());
//! }

We should add another example documenting a test case that instantiates a contract manually within the test itself, while also showing that they can change state within a test.

This new example works with regular #[test], but we can leave #[motsu::test] if you think it's better.

Comment on lines +405 to +407
pub const fn new_at(address: Address) -> Self {
Self { address }
}
Copy link
Collaborator

@0xNeshi 0xNeshi Jan 23, 2025

Choose a reason for hiding this comment

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

Maybe it would be more ergonomic to allow devs to pass an arbitrary string (or even number) as a "private key", generating an Address from it. This would be similar to existing conventions in Solidity Foundry & Starknet Foundry (Starknet even enables converting an integer into an address).

Example below - the implementation is a bit longer, but much easier to use in tests:

    // we could even make this generic if you have any suggestions
    #[must_use]
    pub const fn new(priv_key: &str) -> Self {
        let hash = Keccak256::new().update(priv_key.as_bytes()).finalize();
        Self { address: Self::bytes_to_address(&hash) }
    }

    const fn bytes_to_address(bytes: &[u8]) -> Address {
        let mut addr_bytes = [0u8; 20];
        let mut i = 0;
        while i < 20 {
            addr_bytes[i] = bytes[i];
            i += 1;
        }
        Address::new(addr_bytes)
    }

    // Later in tests...

    #[motsu::test]
    fn some_test() {
        // before
        let alice = Account::new_at(address!(
            "A11CEacF9aa32246d767FCCD72e02d6bCbcC375d"
        ));
        let bob = Account::new_at(address!(
            "B0B0cB49ec2e96DF5F5fFB081acaE66A2cBBc2e2"
        ));
        // after
        let alice = Account::new("alice");
        let bob = Account::new("bob");
        // ...
    }

We also make it clear that we prefer devs manually set addresses in order to avoid potential collisions (although the chances are fairly low). This also ties naturally into how real EOAs are instantiated (with private keys), implying that priv_key should be unique, and if there are collisions, it's the dev's fault.
If devs still prefer to get random address, they can use ::random().
Imo this strikes a perfect balance between safety and devX

Copy link
Member Author

@qalisander qalisander Jan 23, 2025

Choose a reason for hiding this comment

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

Yeah. We can add it.
We can do so even better than "Foundry"s. On macro step, like this example:

#[motsu::test]
fn some_test(erc20: Contract<Erc20>, alice: Account, bob: Account) {
    ...
}

Can be dissugared to:

#[test]
fn some_test() {
    let test = |erc20: Contract<Erc20>, alice: Account, bob: Account| {
          ... 
    };
    test(Contract::<Erc20>::from_str("erc20"), Account::from_str("alice"), Account::from_str("bob"));
}

And rust would give compile time warranty, that argument name will be unique. And hence the string and address generated from it.

@0xNeshi @bidzyyys But maybe add smth like that a bit later?

Copy link
Member Author

@qalisander qalisander Jan 23, 2025

Choose a reason for hiding this comment

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

Also with this approach, we can even add a standard Debug and Display implementation, that can consider stringify!ed variable name.

Copy link
Member Author

Choose a reason for hiding this comment

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

My example is a bit wrong, updated it

Copy link
Collaborator

@0xNeshi 0xNeshi Jan 23, 2025

Choose a reason for hiding this comment

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

We can do so even better than "Foundry"s. On macro step...

I think this actually brings value back to the #[motsu::test] attribute! I really like this.

But maybe add smth like that a bit later?

I'm fine with adding this later. This would mean we leave new_at(Address), and later we can just add the additional new(&str) (I think new is appropriate for this, but we can use a different one).
We'll be giving more options to the developer to use the tool as they like.

Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

[Feature]: Allow setting msg::sender in unit tests [Feature]: Mock another contract in #[motsu::test]
3 participants