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

[test] Allow test scripts to import types from deployed contracts #197

Merged
merged 2 commits into from
Sep 8, 2023

Conversation

m-Peter
Copy link
Contributor

@m-Peter m-Peter commented Aug 21, 2023

Description

Below is a working example of utilizing types from deployed contracts, inside of a test script:

import Test
// The address `0x0000000000000005` comes from the account created in the lines below.
// This is where the `FooContract` is also deployed in the `setup()` function.
// However, we can't avoid the chicken-and-egg problem, developers will have to probably
// log the account to get the address, which will be sequential and guaranteed to be the same.
// import FooContract from account.address is not possible.
import FooContract from 0x0000000000000005

pub let blockchain = Test.newEmulatorBlockchain()
pub let account = blockchain.createAccount()

pub fun setup() {
    let contractCode = Test.readFile("../contracts/FooContract.cdc")
    let err = blockchain.deployContract(
        name: "FooContract",
        code: contractCode,
        account: account,
        arguments: []
    )

    Test.expect(err, Test.beNil())

    blockchain.useConfiguration(Test.Configuration({
        "../contracts/FooContract.cdc": account.address
    }))
}

pub fun testGetSpecialNumber() {
    let script = Test.readFile("../scripts/get_special_number.cdc")
    let result = blockchain.executeScript(script, [])

    Test.expect(result, Test.beSucceeded())

    // Type-cast from `AnyStruct` to the `SpecialNumber` type defined on `FooContract` contract
    let specialNumbers = result.returnValue! as! [FooContract.SpecialNumber]
    let specialNumber = specialNumbers[0]

    // We can even create an instance of the `SpecialNumber` type, and use it in assertions
    let expected = FooContract.SpecialNumber(n: 1729, trait: "Harshad")
    Test.assertEqual(expected, specialNumber)
}

pub fun testAddSpecialNumber() {
    let code = Test.readFile("../transactions/add_special_number.cdc")
    let tx = Test.Transaction(
        code: code,
        authorizers: [account.address],
        signers: [account],
        arguments: [78557, "Sierpinski"]
    )

    let result = blockchain.executeTransaction(tx)
    Test.expect(result, Test.beSucceeded())

    // No need to construct `CompositeType` for filtering blockchain events
    let typ = Type<FooContract.NumberAdded>()
    let events = blockchain.eventsOfType(typ)
    Test.assertEqual(1, events.length)

    // We can even type-cast events from `AnyStruct` to their actual type, and use
    // this object to perform assertions on the event payload.
    let event = events[0] as! FooContract.NumberAdded
    Test.assertEqual(78557, event.n)
    Test.assertEqual("Sierpinski", event.trait)
}

  • Targeted PR against master branch
  • Linked to Github issue with discussion and accepted design OR link to spec that describes this work
  • Code follows the standards mentioned here
  • Updated relevant documentation
  • Re-reviewed Files changed in the Github PR explorer
  • Added appropriate labels

test/test_runner.go Outdated Show resolved Hide resolved
Copy link
Member

@SupunS SupunS 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 a bit skeptical about supporting this tbh (It looks cool to see this working nevertheless! and also great work to get it working).

The main reason is (also as you've mentioned) that users wouldn't know the address of the contract.

However, we can't avoid the chicken-and-egg problem, developers will have to probably
log the account to get the address.

This assumes blockchain.createAccount() always creates the same account (with the same address) in each run, which might not always be the case.

test/emulator_backend.go Outdated Show resolved Hide resolved
@m-Peter
Copy link
Contributor Author

m-Peter commented Aug 30, 2023

This assumes blockchain.createAccount() always creates the same account (with the same address) in each run, which might not always be the case.

One way around this could be the use of flow.MonotonicEmulator.Chain(), enabling simple addresses, which are sequential and starting with 0x01 etc.

With this, the addresses for created accounts, are guaranteed to be the same in each run:

import Test
import FooContract from 0x0000000000000005

pub let blockchain = Test.newEmulatorBlockchain()
pub let account = blockchain.createAccount()

I have implemented this approach in this commit: 2817523

Another workaround could be to deploy contracts to blockchain.serviceAccount(), which seems rather limiting, but the address is not likely to change(famous last words 😂 ), e.g.:

import Test
import FooContract from 0xf8d6e0586b0a20c7

pub let blockchain = Test.newEmulatorBlockchain()
pub let admin = blockchain.serviceAccount()

pub fun setup() {
    let contractCode = Test.readFile("../contracts/FooContract.cdc")
    let err = blockchain.deployContract(
        name: "FooContract",
        code: contractCode,
        account: admin,
        arguments: []
    )

    Test.expect(err, Test.beNil())

    blockchain.useConfiguration(Test.Configuration({
        "../contracts/FooContract.cdc": admin.address
    }))
}

What are your thoughts on these?

@SupunS
Copy link
Member

SupunS commented Aug 31, 2023

Nice, using flow.MonotonicEmulator seems a much better approach 👌

This will guarantee that the addresses of created accounts will be
sequential and start from 0x01. Thus, allowing developers to safely
import types of deployed contracts.
@m-Peter m-Peter force-pushed the reference-deployed-contract-types branch from 2817523 to 767d32b Compare September 8, 2023 07:13
@m-Peter m-Peter requested a review from SupunS September 8, 2023 09:39
@bluesign
Copy link
Contributor

bluesign commented Sep 8, 2023

How about:

import Test
import FooContract from "../contracts/FooContract.cdc"

pub let blockchain = Test.newEmulatorBlockchain()
pub let admin = blockchain.serviceAccount()

pub fun setup() {
    let err = blockchain.deployContract(
        name: "FooContract",
        source: "../contracts/FooContract.cdc",
        account: admin,
        arguments: []
    )
    Test.expect(err, Test.beNil())
}

this way we will also have editor support ( I assume )

@SupunS knows better but probably we can have import handler ( as we track on deploy address and source location )

@m-Peter
Copy link
Contributor Author

m-Peter commented Sep 8, 2023

@bluesign You are correct, this would give us editor support. However, we need the address to "trick" the type checker into thinking that the types returned from blockchain scripts, are indeed imported in the test script.

For example, if a script returns the FooContract.NumberAdded nested type (assuming it is deployed on 0x0000000000000005), then in the test script, we need to import from that specific address, for the following to work:

let typ = Type<FooContract.NumberAdded>()
let events = blockchain.eventsOfType(typ)
let event = events[0] as! FooContract.NumberAdded
Test.assertEqual(78557, event.n)
Test.assertEqual("Sierpinski", event.trait)

( as we track on deploy address and source location )

This seems promising though 👌 , it didn't cross my mind that we do track both deploy address and source location. I will try to see if I can use the relative path import, and replace it with the actual deploy address in the import handler.

@bluesign
Copy link
Contributor

bluesign commented Sep 8, 2023

it didn't cross my mind that we do track both deploy address and source location. I will try to see if I can use the relative path import, and replace it with the actual deploy address in the import handler.

yeah I changed the signature of deploy in the example for that purpose, can be some new function like: blockchain.deployContractFromPath etc.

then we can inject imports after setup phase. ( I assume setup is running before )

@m-Peter
Copy link
Contributor Author

m-Peter commented Sep 8, 2023

Oh right 😅 Right now we are just passing the code. Good point 👍
The setup phase runs once, before test cases.
However, the parsing and checking of imports has already been done. I have to check if we can do something in interpreterContractValueHandler.

test/emulator_backend.go Outdated Show resolved Hide resolved
@SupunS
Copy link
Member

SupunS commented Sep 8, 2023

Yeah, I agree with @bluesign, we could try to make the developer experience even more smoother, if possible.

I was also thinking it might be a good idea to merge the two environments. e.g.: onflow/cadence#1993. I have been advocating against it earlier, but you are right, and I'm also starting to feel it might be actually good. i.e: It might be OK to just have one blockchain per test suite/file, and both the test script and the 'testing codes' share that.

@m-Peter Can you maybe open another issue for this? So we can discuss it further. We could also propose this change as a FLIP if needed. But might have to do some proof-of-concept on what's possible and how would it work afterward, before we propose.

@m-Peter
Copy link
Contributor Author

m-Peter commented Sep 8, 2023

Yeah, I agree with @bluesign, we could try to make the developer experience even more smoother, if possible.

@SupunS & @bluesign Unfortunately this is hard to overcome. The reason is that imports from StringLocation, are a sign that the developer will be writing unit tests, no blockchain involved, and the contract has to be instantiated within the test script, e.g:

import FooContract from "../contracts/FooContract.cdc"

pub let fooContract = FooContract()

If I try to cast a contract nested type, returned from a script executed against a blockchain, I get the following error:

error: failed to force-cast value: expected type `[S.../contracts/FooContract.cdc.FooContract.SpecialNumber]`, got `[A.0000000000000005.FooContract.SpecialNumber]`

I was also thinking it might be a good idea to merge the two environments. e.g.: onflow/cadence#1993. I have been advocating against it earlier, but you are right, and I'm also starting to feel it might be actually good. i.e: It might be OK to just have one blockchain per test suite/file, and both the test script and the 'testing codes' share that.

The two environments here being the script environment and the one from EmulatorBackend's blockchain, right? It is possible that it may simplify things, but I can't know for sure, before I see the end result 😅

@m-Peter Can you maybe open another issue for this? So we can discuss it further. We could also propose this change as a FLIP if needed. But might have to do some proof-of-concept on what's possible and how would it work afterward, before we propose.

Sure thing, I'll create another issue for this 👍

@SupunS SupunS merged commit d0d3d6f into onflow:master Sep 8, 2023
@bluesign
Copy link
Contributor

bluesign commented Sep 8, 2023

How currently tests are run ? When file is loaded there is no FooContract at 0xf8d6e0586b0a20c7 no ? Is setup somehow running before hand?

import Test
import FooContract from 0xf8d6e0586b0a20c7

pub let blockchain = Test.newEmulatorBlockchain()
pub let admin = blockchain.serviceAccount()

pub fun setup() {
    let contractCode = Test.readFile("../contracts/FooContract.cdc")
    let err = blockchain.deployContract(
        name: "FooContract",
        code: contractCode,
        account: admin,
        arguments: []
    )

    Test.expect(err, Test.beNil())

    blockchain.useConfiguration(Test.Configuration({
        "../contracts/FooContract.cdc": admin.address
    }))
}

@m-Peter
Copy link
Contributor Author

m-Peter commented Sep 8, 2023

@bluesign You are right, there is no FooContract at 0xf8d6e0586b0a20c7. We simply ignore the address part, and load the source code from its respective file. With this trick, when we try to cast return values from scripts, then we have the correct type to make the type checker happy. E.g Type<FooContract>() => f8d6e0586b0a20c7.FooContract. This is mainly to get access to the nested types defined in FooContract, and not to actually interact with this. Scripts & transactions should still be used to get data and mutate the contract's state. I hope this clarifies things a bit more.

@bluesign
Copy link
Contributor

bluesign commented Sep 8, 2023

@m-Peter all clear now, thanks a lot. I think @SupunS suggestion of merging environments still not solving the issue though. As we cannot import before we deploy.

I think the easiest way would be, swizzling the declaration on deploy contract. import FooContract from "../contracts/FooContract.cdc" declares a FooContract global variable, when we call blockchain.deployContract I believe we can inject the FooContract from sub blockchain and replace existing one. Not sure about the checker though.

@m-Peter
Copy link
Contributor Author

m-Peter commented Sep 8, 2023

As we cannot import before we deploy.

That's exactly right @bluesign . I have mentioned this problem in the description:

// However, we can't avoid the chicken-and-egg problem, developers will have to probably
// log the account to get the address, which will be sequential and guaranteed to be the same.
// import FooContract from account.address is not possible.

@SupunS
Copy link
Member

SupunS commented Sep 8, 2023

Yes, correct, merging two won't solve it. Even if we merge the two, it would need to construct the contract value later, at the time of the deployContract() is invoked (We would still have to do what we do right now under the hood)

However, by merging, we might be able to remove some of the pain points for developers, e.g: there would not be a blockchain
blockchain.deployContract() would be simply deployContract(). And the unit tests and integration tests would become one thing.
e.g:

Currently unit tests:

import FooContract from "../contracts/FooContract.cdc"

var foo = FooContract()  // construct value
foo.sayHello()           // and then call the function

But after:

import FooContract from "../contracts/FooContract.cdc"

FooContract.sayHello()  // FooContract is already the instance, just like in integration tests. So can directly call the function

So it is unified for both integration tests and unit tests.

We would also be able to get rid of setting the configurations for the blockchain. i.e:

blockchain.useConfiguration(Test.Configuration({
        "../contracts/FooContract.cdc": admin.address
    }))

Instead can make use of the flow.json directly.

@bluesign
Copy link
Contributor

bluesign commented Sep 8, 2023

yeah I just looked at this from integration tests perspective, but I agree unit tests would be much better if we merge.

@m-Peter m-Peter deleted the reference-deployed-contract-types branch October 3, 2023 07:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants