arrow-kt / arrow-proofs

Arrow Proofs Plugin
Apache License 2.0
10 stars 3 forks source link

Local providers #30

Open JavierSegoviaCordoba opened 2 years ago

JavierSegoviaCordoba commented 2 years ago

This feature is to allow providing proofs with different parents, so instead of getting an ambiguous proof error, we get the closest one.

It is totally oriented to testing, but it can/could be used outside of tests if needed.

Android common architecture example.

It is usual to have this layers:

Generally, one or more datasources are passed via repositories constructor implementation. At same time, one or repositories are passed via constructor to the use cases. Finally the ViewModel receives via constructor one or more use cases and the ViewModel is injected into the UI.

As you can see, technically you can create an integration test (understanding integration tests as a test that tests multiple components) for ViewModels manually by passing all instances via the constructor.

That process can be tedious and in those kinds of tests is in which a DI framework can shine.

If I want to test my ViewModel, in a lot of applications I only want to change the remote by using a mock web server or a mock engine if you are using Ktor, or in other words, providing multiple local JSON to be able to tests multiple paths, not only multiple happy paths, errors, and so on.

At the same time, those tests are written like unit tests, there is no emulator, so they run quickly, and testing the ViewModel implies you are testing the rest of the layers. In my opinion unit testing shines on libraries or edge cases, but in applications, the integration tests are the best way to get a robust product.

So I want to build tests that can have a complex graph of dependencies without maintaining manually that graph, so a simple test can be:

class MyTest {

    @Test
    `some viewmodel test`() {
         // I need a way to replace the ktor engine (`OkHhttp`) with the `MockEngine`
         // in order to do a test   
         // ...
    }
}

Our plugin should allow doing easily:

class MyTest {

    @Provider 
    val engine = MockEngine()

    @Test
    `some viewmodel test`() {
         // ...
    }
}

Or even, if possible

class MyTest {

    @Test
    `some viewmodel test 1`() {
         @Provider 
         val engine = MockEngine()
         // ...
    }

    @Test
    `some viewmodel test 2`() {
         @Provider 
         val engine = MockEngine()
         // ...
    }
}

Probably there are use cases outside tests, I am not totally sure, but I would add a flag to block this usage to only tests, and enable it globally with that flag.

i-walker commented 2 years ago

I am also in favor of this feature, but with a few more restrictions regarding the quantity, precedence and relationship between internal orphans and provider instances, which allows us to use @Provider in main and test. Also as a first draft for coherence among provider instances, public proofs and internal orphans.

Impact of provider instances on the overall Proof resolution

I hope we agree that the overall assumption of provider instances is that they obey a subset of rules that internal orphans have, but never impact the proof resolution in a public matter like public proofs do - ergo they never change the proof resolution of 3rd parties.

Compatibility and compiler integration

We could add a compiler flag, which overrules an internal orphan when an @Provider is in the same package - and essentially populates the proof resolution with provider instances. Projects without that compiler flag compile fine and obey the base proof resolution rules with public instances and internal orphans.

Proof resolution - Achieving package-level coherence

I imagine this behavior for proof resolution with provider instances - I am leaving out use cases of public instances since internal orphans and providers overrule them.

package-level coherence specifically canceling out cases like below:

package com.mypackage

class MyTest {

    @Test
    `some viewmodel test 1`() {
         @Provider 
         val engine = MockEngine()
         // ...
    }

    @Test
    `some viewmodel test 2`() {
         @Provider 
         val engine = MockEngine()
         // ...
    }
}

where we would have to analyze both the package and the scope for coherence since we expect this to fail:

package com.mypackage

class MyTest {

    @Test
    `some viewmodel test 1`() {
         @Provider 
         val engine = MockEngine()
         @Provider
         val otherEngine = MockEngine()
         // ...
    }
}

The first proposal is package scoped resolution of provider instances: The resolution here works both in test or main directory and discovery of an instance is based on either the same package directory or it uses the internal orphan (or public in cases there is no internal orphan) - I will refer to it as package-level coherence.

package com.mypackage

// Environment: an internal orphan exists in this project (whether in main or test) - in this case there is no behavior change 
// wether an internal orphan exists in main or test since there can only exist one internal orphan per project, and in the case that // it lives in test it changes the resolution in the test directory but not in main (main does not have the scope of descriptors in test)

class MyTest {

    @Provider 
     val engine = MockEngine()

    @Test
    `some viewmodel test`() {
         // ... the proof resolution picks `engine`
    }
}

It fails in cases where in the same package multiple @Provider exist, but other provider instances can exist in another package directories like com.mypackage.model.

If no provider instances is found in the same package directory, the existing proof resolution resolves the instance.

In cases where in main a provider instance exists and another one in test, we expect an Error, or we can loosen this restriction by adding a @ProviderTest in cases where we need both. Both still need to obey package-level coherence.

Please add any feedback! It would be awesome if we can loosen these proposed rules to introspect provider instances based on the nearest scope of any given call-site that would be awesome, but I don't know yet if we have those informations in the compiler phase.

JavierSegoviaCordoba commented 2 years ago

If I understood you correctly, we must not allow having multiple providers with the same parent. internal overrides the public one if both have the same parent, but if the local is deeper, the local is used, right? For example:

// This is used in all places except `SomeClass` and `SomeInnerClass`
@Provider internal fun foo(): Bar

class SomeClass {
    // This is used inside `SomeClass` except `SomeInnerClass`
    @Provider internal fun foo2(): Bar

    inner class SomeInnerClass {
        // This is used inside `SomeInnerClass`
        @Provider fun foo3(): Bar 
    }
}
i-walker commented 2 years ago

That example is one that I am excluding out. This will fail since there are multiple Providers in the same package. I can also jump on a call if you have questions.

The idea is to separate different Providers into their respective packages.

so for a given Type class Bar

// new file 
package com.mypackage

@Provider
fun foo(): Bar

// new file
package com.mypackage.model

@Provider
fun foo(): Bar

Both of these can coexist and only influence the provider coherence and discovery of the instance based on their package. So Code depending on a Bar instance in package com.mypackage.model will pick com.mypackage.model.foo and code depending on an instance of Bar in com.mypackage will choose com.mypackage.foo.

That is the base idea.