expo-modules-test-core 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +12 -2
- package/android/src/main/java/expo/modules/test/core/ModuleMock.kt +90 -24
- package/android/src/main/java/expo/modules/test/core/ModuleMockHolder.kt +5 -3
- package/android/src/main/java/expo/modules/test/core/ModuleMockInvocationHandler.kt +27 -8
- package/android/src/main/java/expo/modules/test/core/PromiseMock.kt +5 -0
- package/android/src/main/java/expo/modules/test/core/TestJSContainerProvider.kt +1 -1
- package/android/src/main/java/expo/modules/test/core/TestUtils.kt +36 -21
- package/package.json +2 -2
package/android/build.gradle
CHANGED
|
@@ -3,7 +3,7 @@ apply plugin: 'kotlin-android'
|
|
|
3
3
|
apply plugin: 'maven-publish'
|
|
4
4
|
|
|
5
5
|
group = 'org.unimodules'
|
|
6
|
-
version = '0.
|
|
6
|
+
version = '0.9.0'
|
|
7
7
|
|
|
8
8
|
buildscript {
|
|
9
9
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
@@ -74,7 +74,7 @@ android {
|
|
|
74
74
|
minSdkVersion safeExtGet("minSdkVersion", 21)
|
|
75
75
|
targetSdkVersion safeExtGet("targetSdkVersion", 31)
|
|
76
76
|
versionCode 3
|
|
77
|
-
versionName '0.
|
|
77
|
+
versionName '0.9.0'
|
|
78
78
|
}
|
|
79
79
|
lintOptions {
|
|
80
80
|
abortOnError false
|
|
@@ -98,3 +98,13 @@ dependencies {
|
|
|
98
98
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
|
99
99
|
implementation "org.jetbrains.kotlin:kotlin-reflect:${getKotlinVersion()}"
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* To make the users of annotations @OptIn and @RequiresOptIn aware of their experimental status,
|
|
104
|
+
* the compiler raises warnings when compiling the code with these annotations:
|
|
105
|
+
* This class can only be used with the compiler argument '-Xopt-in=kotlin.RequiresOptIn'
|
|
106
|
+
* To remove the warnings, we add the compiler argument -Xopt-in=kotlin.RequiresOptIn.
|
|
107
|
+
*/
|
|
108
|
+
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
|
109
|
+
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
|
|
110
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package expo.modules.test.core
|
|
2
2
|
|
|
3
|
+
import android.content.Context
|
|
3
4
|
import android.os.Bundle
|
|
4
5
|
import androidx.test.core.app.ApplicationProvider
|
|
5
6
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
@@ -7,6 +8,8 @@ import expo.modules.core.interfaces.services.EventEmitter
|
|
|
7
8
|
import expo.modules.kotlin.AppContext
|
|
8
9
|
import expo.modules.kotlin.ModuleHolder
|
|
9
10
|
import expo.modules.kotlin.modules.Module
|
|
11
|
+
import io.mockk.MockK
|
|
12
|
+
import io.mockk.MockKGateway
|
|
10
13
|
import io.mockk.every
|
|
11
14
|
import io.mockk.mockk
|
|
12
15
|
import io.mockk.spyk
|
|
@@ -14,26 +17,39 @@ import java.lang.ref.WeakReference
|
|
|
14
17
|
import java.lang.reflect.Proxy
|
|
15
18
|
import kotlin.reflect.KClass
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
/**
|
|
21
|
+
* This class shouldn't be used directly. Instead use
|
|
22
|
+
* ```kotlin
|
|
23
|
+
* ModuleMock.createMock(MyModuleTestInterface::class, MyModule()) {
|
|
24
|
+
* // module test code here
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
data class ModuleMock<TestInterfaceType : Any, ModuleType : Module>(
|
|
29
|
+
val testInterface: TestInterfaceType,
|
|
30
|
+
val appContext: AppContext,
|
|
31
|
+
val eventEmitter: EventEmitter,
|
|
32
|
+
val moduleSpy: ModuleType
|
|
33
|
+
) {
|
|
18
34
|
companion object {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
/**
|
|
36
|
+
* This overload shouldn't be used directly. Instead use
|
|
37
|
+
* the inline overload with `block` being the last argument:
|
|
38
|
+
* `ModuleMock.createMock(..., block: (...) -> Unit)` instead
|
|
39
|
+
*/
|
|
40
|
+
fun <TestInterfaceType : Any, ModuleType : Module> createMock(
|
|
41
|
+
moduleTestInterface: KClass<TestInterfaceType>,
|
|
42
|
+
module: ModuleType,
|
|
22
43
|
customAppContext: AppContext? = null,
|
|
23
|
-
customEventEmitter: EventEmitter? = null
|
|
24
|
-
):
|
|
25
|
-
val
|
|
26
|
-
val appContext = customAppContext ?: AppContext(
|
|
27
|
-
modulesProvider = mockk(relaxed = true),
|
|
28
|
-
legacyModuleRegistry = mockk(relaxed = true),
|
|
29
|
-
reactContextHolder = WeakReference(context)
|
|
30
|
-
)
|
|
31
|
-
|
|
44
|
+
customEventEmitter: EventEmitter? = null
|
|
45
|
+
): ModuleMock<TestInterfaceType, ModuleType> {
|
|
46
|
+
val appContext = prepareMockAppContext(customAppContext)
|
|
32
47
|
val eventEmitter: EventEmitter = customEventEmitter ?: mockk(relaxed = true)
|
|
33
48
|
|
|
34
|
-
|
|
49
|
+
// prepare module spy
|
|
50
|
+
val moduleSpy = convertToSpy(module, recordPrivateCalls = true)
|
|
35
51
|
every { moduleSpy getProperty "appContext" } returns appContext
|
|
36
|
-
every { moduleSpy.sendEvent(any(), any()) } answers { call ->
|
|
52
|
+
every { moduleSpy.sendEvent(any(), any<Bundle>()) } answers { call ->
|
|
37
53
|
val (eventName, eventBody) = call.invocation.args
|
|
38
54
|
eventEmitter.emit(eventName as String, eventBody as? Bundle)
|
|
39
55
|
}
|
|
@@ -47,34 +63,48 @@ class ModuleMock {
|
|
|
47
63
|
holder
|
|
48
64
|
)
|
|
49
65
|
@Suppress("UNCHECKED_CAST")
|
|
50
|
-
return
|
|
66
|
+
return ModuleMock(
|
|
51
67
|
Proxy
|
|
52
68
|
.newProxyInstance(
|
|
53
69
|
moduleTestInterface.java.classLoader,
|
|
54
70
|
arrayOf(moduleTestInterface.java, ModuleController::class.java),
|
|
55
71
|
invocationHandler
|
|
56
|
-
) as
|
|
72
|
+
) as TestInterfaceType,
|
|
57
73
|
appContext,
|
|
58
|
-
eventEmitter
|
|
74
|
+
eventEmitter,
|
|
75
|
+
moduleSpy
|
|
59
76
|
)
|
|
60
77
|
}
|
|
61
78
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Executes the given [block] in the mocked module scope.
|
|
81
|
+
* Example usage:
|
|
82
|
+
* ```kotlin
|
|
83
|
+
* ModuleMock.createMock(MyModuleTestInterface::class, MyModule()) {
|
|
84
|
+
* every { moduleSpy.someModulePrivateFn() } returns 5
|
|
85
|
+
* val result = module.someFunctionAsync()
|
|
86
|
+
* assertEquals(result, 5)
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
inline fun <TestInterfaceType : Any, ModuleType : Module> createMock(
|
|
91
|
+
moduleTestInterface: KClass<TestInterfaceType>,
|
|
92
|
+
module: ModuleType,
|
|
65
93
|
autoOnCreate: Boolean = true,
|
|
66
94
|
customAppContext: AppContext? = null,
|
|
67
95
|
customEventEmitter: EventEmitter? = null,
|
|
68
|
-
block: ModuleMockHolder<
|
|
96
|
+
block: ModuleMockHolder<TestInterfaceType, ModuleType>.() -> Unit
|
|
69
97
|
) {
|
|
70
|
-
val (mock, appContext, eventEmitter) = createMock(
|
|
98
|
+
val (mock, appContext, eventEmitter, moduleSpy) = createMock(
|
|
71
99
|
moduleTestInterface,
|
|
72
100
|
module,
|
|
73
101
|
customAppContext,
|
|
74
102
|
customEventEmitter
|
|
75
103
|
)
|
|
76
104
|
val controller = mock as ModuleController
|
|
77
|
-
val holder = ModuleMockHolder<
|
|
105
|
+
val holder = ModuleMockHolder<TestInterfaceType, ModuleType>(
|
|
106
|
+
mock, controller, appContext, eventEmitter, moduleSpy
|
|
107
|
+
)
|
|
78
108
|
|
|
79
109
|
if (autoOnCreate) {
|
|
80
110
|
controller.onCreate()
|
|
@@ -84,3 +114,39 @@ class ModuleMock {
|
|
|
84
114
|
}
|
|
85
115
|
}
|
|
86
116
|
}
|
|
117
|
+
|
|
118
|
+
private fun prepareMockAppContext(customAppContext: AppContext?): AppContext {
|
|
119
|
+
val reactContext = ReactApplicationContext(ApplicationProvider.getApplicationContext<Context>())
|
|
120
|
+
val appContext = customAppContext ?: AppContext(
|
|
121
|
+
modulesProvider = mockk(relaxed = true),
|
|
122
|
+
legacyModuleRegistry = mockk(relaxed = true),
|
|
123
|
+
reactContextHolder = WeakReference(reactContext)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// as AppContext holds only weak reference to Android Context which can be destroyed too early
|
|
127
|
+
// we need to override it to return actual strong reference (held by mockk internals)
|
|
128
|
+
val appContextSpy = convertToSpy(appContext)
|
|
129
|
+
every { appContextSpy getProperty "reactContext" } returns reactContext
|
|
130
|
+
|
|
131
|
+
return appContextSpy
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Creates a spy from a given object or returns it as-is if it's already a spy
|
|
136
|
+
*/
|
|
137
|
+
private fun <T : Any> convertToSpy(obj: T, recordPrivateCalls: Boolean = false): T =
|
|
138
|
+
MockK.useImpl {
|
|
139
|
+
return@useImpl if (MockKGateway.implementation().mockTypeChecker.isSpy(obj)) {
|
|
140
|
+
obj
|
|
141
|
+
} else {
|
|
142
|
+
// this is actually spyk<T>(obj) but without syntax sugar
|
|
143
|
+
// because we're already inside MockK.useImpl { } which is part of that sugar
|
|
144
|
+
MockKGateway.implementation().mockFactory.spyk(
|
|
145
|
+
mockType = null, // this should be null if objToCopy is provided
|
|
146
|
+
objToCopy = obj,
|
|
147
|
+
name = null,
|
|
148
|
+
moreInterfaces = emptyArray(),
|
|
149
|
+
recordPrivateCalls = recordPrivateCalls
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -2,10 +2,12 @@ package expo.modules.test.core
|
|
|
2
2
|
|
|
3
3
|
import expo.modules.core.interfaces.services.EventEmitter
|
|
4
4
|
import expo.modules.kotlin.AppContext
|
|
5
|
+
import expo.modules.kotlin.modules.Module
|
|
5
6
|
|
|
6
|
-
data class ModuleMockHolder<
|
|
7
|
-
val module:
|
|
7
|
+
data class ModuleMockHolder<TestInterfaceType, ModuleType : Module>(
|
|
8
|
+
val module: TestInterfaceType,
|
|
8
9
|
val controller: ModuleController,
|
|
9
10
|
val appContext: AppContext,
|
|
10
|
-
val eventEmitter: EventEmitter
|
|
11
|
+
val eventEmitter: EventEmitter,
|
|
12
|
+
val moduleSpy: ModuleType
|
|
11
13
|
)
|
|
@@ -22,14 +22,17 @@ class TestCodedException(
|
|
|
22
22
|
* exported function or to the module controller if the method doesn't exist in the module definition.
|
|
23
23
|
*
|
|
24
24
|
* Methods mapping:
|
|
25
|
-
*
|
|
25
|
+
* AsyncFunction("name") { args: ArgsType -> return ReturnType } can be invoked using one of the following methods mapping rules:
|
|
26
26
|
* - [non-promise mapping] fun ModuleTestInterface.name(args: ArgsType): ReturnType
|
|
27
27
|
* - [promise mapping] fun ModuleTestInterface.name(args: ArgsType, promise: Promise): Unit
|
|
28
28
|
*
|
|
29
|
-
*
|
|
29
|
+
* AsyncFunction("name") { args: ArgsType, promise: Promise -> promise.resolve(ReturnType) } can be invoked using one of the following methods mapping rules:
|
|
30
30
|
* - [non-promise mapping] fun ModuleTestInterface.name(args: ArgsType): ReturnType
|
|
31
31
|
* - [promise mapping] fun ModuleTestInterface.name(args: ArgsType, promise: Promise): Unit
|
|
32
32
|
*
|
|
33
|
+
* Function("name") { args: ArgsType -> return ReturnType } can be invoked using non-promise mapping only:
|
|
34
|
+
* - fun ModuleTestInterface.name(args: ArgsType): ReturnType
|
|
35
|
+
*
|
|
33
36
|
* In tests, the non-promise mapping should be preferred if possible.
|
|
34
37
|
* The promise mapping should be only used when dealing with native async code.
|
|
35
38
|
*
|
|
@@ -43,7 +46,9 @@ class ModuleMockInvocationHandler<T : Any>(
|
|
|
43
46
|
private val holder: ModuleHolder
|
|
44
47
|
) : InvocationHandler {
|
|
45
48
|
override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? {
|
|
46
|
-
if (!holder.definition.
|
|
49
|
+
if (!holder.definition.asyncFunctions.containsKey(method.name) &&
|
|
50
|
+
!holder.definition.syncFunctions.containsKey(method.name)
|
|
51
|
+
) {
|
|
47
52
|
return method.invoke(moduleController, *(args ?: emptyArray()))
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -51,13 +56,23 @@ class ModuleMockInvocationHandler<T : Any>(
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
private fun callExportedFunction(methodName: String, args: Array<out Any>?): Any? {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return Unit
|
|
59
|
+
if (holder.definition.syncFunctions.containsKey(methodName)) {
|
|
60
|
+
// Call as a sync function
|
|
61
|
+
return holder.callSync(methodName, convertArgs(args?.asList() ?: emptyList()))
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
|
|
64
|
+
if (holder.definition.asyncFunctions.containsKey(methodName)) {
|
|
65
|
+
// We know it's a async function, but we don't know which mapping we're using
|
|
66
|
+
val lastArg = args?.lastOrNull()
|
|
67
|
+
if (Promise::class.java.isInstance(lastArg)) {
|
|
68
|
+
promiseMappingCall(methodName, args!!.dropLast(1), lastArg as Promise)
|
|
69
|
+
return Unit
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return nonPromiseMappingCall(methodName, args)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw IllegalStateException("Module class method '$methodName' not found")
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
private fun nonPromiseMappingCall(methodName: String, args: Array<out Any>?): Any? {
|
|
@@ -98,6 +113,10 @@ class ModuleMockInvocationHandler<T : Any>(
|
|
|
98
113
|
holder.call(methodName, convertArgs(args), promise)
|
|
99
114
|
}
|
|
100
115
|
|
|
116
|
+
private fun syncCall(methodName: String, args: Iterable<Any?>): Any? {
|
|
117
|
+
return holder.callSync(methodName, convertArgs(args))
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
private fun convertArgs(args: Iterable<Any?>): ReadableArray {
|
|
102
121
|
return JSTypeConverter.convertToJSValue(args, TestJSContainerProvider) as ReadableArray
|
|
103
122
|
}
|
|
@@ -10,6 +10,11 @@ enum class PromiseState {
|
|
|
10
10
|
ILLEGAL
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The [Promise] mock that should be used in conjunction with promise-mapped functions
|
|
15
|
+
* in the module test interface. See [ModuleMockInvocationHandler] documentation for more details
|
|
16
|
+
* @see ModuleMockInvocationHandler
|
|
17
|
+
*/
|
|
13
18
|
class PromiseMock : Promise {
|
|
14
19
|
|
|
15
20
|
var state = PromiseState.NONE
|
|
@@ -6,7 +6,7 @@ import com.facebook.react.bridge.WritableArray
|
|
|
6
6
|
import com.facebook.react.bridge.WritableMap
|
|
7
7
|
import expo.modules.kotlin.types.JSTypeConverter
|
|
8
8
|
|
|
9
|
-
object TestJSContainerProvider : JSTypeConverter.ContainerProvider {
|
|
9
|
+
internal object TestJSContainerProvider : JSTypeConverter.ContainerProvider {
|
|
10
10
|
override fun createMap(): WritableMap = JavaOnlyMap()
|
|
11
11
|
override fun createArray(): WritableArray = JavaOnlyArray()
|
|
12
12
|
}
|
|
@@ -1,32 +1,47 @@
|
|
|
1
1
|
package expo.modules.test.core
|
|
2
2
|
|
|
3
|
+
import expo.modules.kotlin.exception.CodedException
|
|
3
4
|
import org.junit.Assert
|
|
5
|
+
import java.lang.reflect.UndeclaredThrowableException
|
|
6
|
+
import kotlin.contracts.ExperimentalContracts
|
|
7
|
+
import kotlin.contracts.InvocationKind
|
|
8
|
+
import kotlin.contracts.contract
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Asserts that provided [exception] is a [CodedException]
|
|
12
|
+
*/
|
|
13
|
+
@OptIn(ExperimentalContracts::class)
|
|
14
|
+
fun assertCodedException(exception: Throwable?) {
|
|
15
|
+
contract {
|
|
16
|
+
returns() implies (exception is CodedException)
|
|
17
|
+
}
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
Assert.assertEquals(PromiseState.REJECTED, promise.state)
|
|
11
|
-
}
|
|
19
|
+
Assert.assertNotNull("Expected exception, received null", exception)
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
with(promise.resolveValue as ResolveType)
|
|
20
|
-
}
|
|
21
|
+
if (exception is UndeclaredThrowableException) {
|
|
22
|
+
Assert.fail(
|
|
23
|
+
"Expected CodedException, got UndeclaredThrowableException. " +
|
|
24
|
+
"Did you forget to add '@Throws' annotations to module test interface methods?"
|
|
25
|
+
)
|
|
26
|
+
}
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
if (exception !is CodedException) {
|
|
29
|
+
Assert.fail(
|
|
30
|
+
"Expected CodedException, got ${exception!!::class.simpleName}. " +
|
|
31
|
+
"Full stack trace:\n${exception.stackTraceToString()}"
|
|
32
|
+
)
|
|
33
|
+
}
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Asserts that provided [exception] is a [CodedException] and then executes a block with
|
|
38
|
+
* the [exception] as an argument
|
|
39
|
+
*/
|
|
40
|
+
@OptIn(ExperimentalContracts::class)
|
|
41
|
+
inline fun assertCodedException(exception: Throwable?, block: (exception: CodedException) -> Unit) {
|
|
42
|
+
contract {
|
|
43
|
+
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
|
31
44
|
}
|
|
45
|
+
assertCodedException(exception)
|
|
46
|
+
block(exception)
|
|
32
47
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-modules-test-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"main": "./app.plugin.js",
|
|
5
5
|
"description": "Module providing native testing utilities for testing Expo modules",
|
|
6
6
|
"keywords": [
|
|
@@ -21,5 +21,5 @@
|
|
|
21
21
|
"author": "650 Industries, Inc.",
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"homepage": "https://github.com/expo/expo/tree/main/packages/expo-modules-test-core",
|
|
24
|
-
"gitHead": "
|
|
24
|
+
"gitHead": "6e131f2da851a47c3a24eb3d6fc971a1a7822086"
|
|
25
25
|
}
|