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.
@@ -3,7 +3,7 @@ apply plugin: 'kotlin-android'
3
3
  apply plugin: 'maven-publish'
4
4
 
5
5
  group = 'org.unimodules'
6
- version = '0.8.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.8.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
- class ModuleMock {
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
- fun <T : Any> createMock(
20
- moduleTestInterface: KClass<T>,
21
- module: Module,
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
- ): Triple<T, AppContext, EventEmitter> {
25
- val context = ReactApplicationContext(ApplicationProvider.getApplicationContext())
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
- val moduleSpy = spyk(module)
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 Triple(
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 T,
72
+ ) as TestInterfaceType,
57
73
  appContext,
58
- eventEmitter
74
+ eventEmitter,
75
+ moduleSpy
59
76
  )
60
77
  }
61
78
 
62
- inline fun <T : Any> createMock(
63
- moduleTestInterface: KClass<T>,
64
- module: Module,
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<T>.() -> Unit
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<T>(mock, controller, appContext, eventEmitter)
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<T>(
7
- val module: T,
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
- * function("name") { args: ArgsType -> return ReturnType } can be invoked using one of the following methods mapping rules:
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
- * function("name") { args: ArgsType, promise: Promise -> promise.resolve(ReturnType) } can be invoked using one of the following methods mapping rules:
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.methods.containsKey(method.name)) {
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
- val lastArg = args?.lastOrNull()
55
- if (Promise::class.java.isInstance(lastArg)) {
56
- promiseMappingCall(methodName, args!!.dropLast(1), lastArg as Promise)
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
- return nonPromiseMappingCall(methodName, args)
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
- fun assertResolved(promise: PromiseMock) {
6
- Assert.assertEquals(PromiseState.RESOLVED, promise.state)
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
- fun assertRejected(promise: PromiseMock) {
10
- Assert.assertEquals(PromiseState.REJECTED, promise.state)
11
- }
19
+ Assert.assertNotNull("Expected exception, received null", exception)
12
20
 
13
- inline fun <reified ResolveType> promiseResolved(promise: PromiseMock, with: (ResolveType) -> Unit) {
14
- assertResolved(promise)
15
- Assert.assertTrue(
16
- "Promise resolved with incorrect type: ${ResolveType::class.simpleName}",
17
- promise.resolveValue is ResolveType
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
- fun promiseRejected(promise: PromiseMock, with: (PromiseMock) -> Unit) {
23
- assertRejected(promise)
24
- with(promise)
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
- fun assertRejectedWithCode(promise: PromiseMock, rejectCode: String) {
28
- promiseRejected(promise) {
29
- Assert.assertTrue("Promise has no rejection code", it.rejectCodeSet)
30
- Assert.assertEquals(it.rejectCode, rejectCode)
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.8.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": "22dce752354bb429c84851bc4389abe47a766b1f"
24
+ "gitHead": "6e131f2da851a47c3a24eb3d6fc971a1a7822086"
25
25
  }