Skip to content

Commit

Permalink
[media-library] Add unit tests to module (#9538)
Browse files Browse the repository at this point in the history
# Why
MediaLibrary has many utility methods, which behavior is sometimes unclear. Unit tests would describe that behavior.

Also, contains some minor fixes.

# How

 - Add native unit tests to `MediaLibrary` on Android.
 - Fixes bug, where `getAssetsAsync()` could return without promise rejection, when caught `IOException`
 - Adds a little input validation to module methods
 - Removes unused test dependencies from `expo-web-browser`. 

# Test plan

`et android-native-unit-tests`

---
WIP

- [x] Add testing dependencies
- [x] Add utility functions to mock `ContentResolver`
- [x] Test `GetQueryInfo`
- [x] Test `MediaLibraryUtils` where possible
- [x] Test `GetAssets`
- [x] Test `GetAssetInfo`
- [x] Extract useful test utility functions to `unimodules-test-core`
- [x] Input validation and minor refactors
  • Loading branch information
barthap committed Aug 5, 2020
1 parent 85c2add commit c6f3677
Show file tree
Hide file tree
Showing 16 changed files with 765 additions and 15 deletions.
3 changes: 3 additions & 0 deletions packages/expo-media-library/CHANGELOG.md
Expand Up @@ -10,6 +10,9 @@

### 🐛 Bug fixes

- Fixed validation for input arguments of `getAssetsAsync`. ([#9538](https://github.com/expo/expo/pull/9538) by [@barthap](https://github.com/barthap))
- Fixed bug, where `getAssetsAsync` did not reject on error on Android. ([#9538](https://github.com/expo/expo/pull/9538) by [@barthap](https://github.com/barthap))

## 8.5.0 — 2020-07-29

### 🎉 New features
Expand Down
20 changes: 17 additions & 3 deletions packages/expo-media-library/android/build.gradle
@@ -1,12 +1,24 @@
apply plugin: 'com.android.library'
apply plugin: 'maven'
apply plugin: 'kotlin-android'

group = 'host.exp.exponent'
version = '8.5.0'

// Simple helper that allows the root project to override versions declared by this library.
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback

buildscript {
// Simple helper that allows the root project to override versions declared by this library.
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}

repositories {
mavenCentral()
}

dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet("kotlinVersion", "1.3.50")}")
}
}

// Upload android library to maven with javadoc and android sources
Expand Down Expand Up @@ -64,5 +76,7 @@ dependencies {
unimodule "unimodules-core"
unimodule "unimodules-permissions-interface"

testImplementation "org.robolectric:robolectric:4.3.1"

api "androidx.exifinterface:exifinterface:1.0.0"
}
Expand Up @@ -60,7 +60,7 @@ protected Void doInBackground(Void... params) {
mPromise.reject(ERROR_UNABLE_TO_LOAD_PERMISSION,
"Could not get asset: need READ_EXTERNAL_STORAGE permission.", e);
} catch (IOException e) {
Log.e(ERROR_UNABLE_TO_LOAD, "Could not read file or parse EXIF tags", e);
mPromise.reject(ERROR_UNABLE_TO_LOAD, "Could not read file or parse EXIF tags", e);
}
return null;
}
Expand Down
Expand Up @@ -414,7 +414,7 @@ static void deleteAssets(Context context, String selection, String[] selectionAr

static String getInPart(String assetsId[]) {
int length = assetsId.length;
String array[] = new String[length];
String[] array = new String[length];
Arrays.fill(array, "?");
return TextUtils.join(",", array);
}
Expand Down
@@ -0,0 +1,121 @@
package expo.modules.medialibrary

import android.os.Bundle
import android.provider.MediaStore
import io.mockk.every
import io.mockk.just
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.unimodules.test.core.PromiseMock
import org.unimodules.test.core.assertListsEqual
import org.unimodules.test.core.assertRejected
import org.unimodules.test.core.assertRejectedWithCode
import org.unimodules.test.core.promiseResolvedWithType
import java.io.IOException

@RunWith(RobolectricTestRunner::class)
internal class GetAssetInfoTests {

private lateinit var promise: PromiseMock
private lateinit var mockContext: MockContext

@Before
fun setUp() {
promise = PromiseMock()
mockContext = MockContext()
}

@Test
fun `GetAssetInfo should call queryAssetInfo`() {
//arrange
val context = mockContext.get()
val selectionSlot = slot<String>()
val selectionArgsSlot = slot<Array<String>>()

mockkStatic(MediaLibraryUtils::class)
every { MediaLibraryUtils.queryAssetInfo(
context,
capture(selectionSlot),
capture(selectionArgsSlot),
true,
promise
) } just runs

val expectedSelection = "${MediaStore.Images.Media._ID}=?"
val assetId = "testAssetId"

//act
GetAssetInfo(context, assetId, promise).doInBackground()

//assert
assertEquals(expectedSelection, selectionSlot.captured)
assertEquals(1, selectionArgsSlot.captured.size)
assertEquals(assetId, selectionArgsSlot.captured[0])
}

@Test
fun `queryAssetInfo should resolve asset`() {
//arrange
val context = mockContext with mockContentResolverForResult(arrayOf(
MockData.mockImage.toColumnArray()
))

mockkStatic(MediaLibraryUtils::class)
every {
MediaLibraryUtils.putAssetsInfo(any(), any(), any(), any(), any(), any())
} just runs

val selection = "${MediaStore.Images.Media._ID}=?"
val selectionArgs = arrayOf(MockData.mockImage.id.toString())

//act
MediaLibraryUtils.queryAssetInfo(context, selection, selectionArgs, false, promise)

//assert
promiseResolvedWithType<ArrayList<Bundle>>(promise) {
assertListsEqual(emptyList<Bundle>(), it)
}
}

@Test
fun `queryAssetInfo should reject on null cursor`() {
//arrange
val context = mockContext with mockContentResolver(null)

//act
MediaLibraryUtils.queryAssetInfo(context, "", emptyArray(), false, promise)

//assert
assertRejected(promise)
}

@Test
fun `queryAssetInfo should reject on SecurityException`() {
//arrange
val context = mockContext with throwableContentResolver(SecurityException())

//act
MediaLibraryUtils.queryAssetInfo(context, "", emptyArray(), false, promise)

//assert
assertRejectedWithCode(promise, MediaLibraryConstants.ERROR_UNABLE_TO_LOAD_PERMISSION)
}

@Test
fun `queryAssetInfo should reject on IOException`() {
//arrange
val context = mockContext with throwableContentResolver(IOException())

//act
MediaLibraryUtils.queryAssetInfo(context, "", emptyArray(), false, promise)

//assert
assertRejectedWithCode(promise, MediaLibraryConstants.ERROR_IO_EXCEPTION)
}
}
@@ -0,0 +1,108 @@
package expo.modules.medialibrary

import expo.modules.medialibrary.MediaLibraryConstants.ERROR_UNABLE_TO_LOAD
import expo.modules.medialibrary.MediaLibraryConstants.ERROR_UNABLE_TO_LOAD_PERMISSION
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.unimodules.test.core.PromiseMock
import org.unimodules.test.core.assertRejectedWithCode
import org.unimodules.test.core.promiseResolved
import java.io.IOException

@RunWith(RobolectricTestRunner::class)
internal class GetAssetsTest {

private lateinit var promise: PromiseMock
private lateinit var mockContext: MockContext

@Before
fun setUp() {
promise = PromiseMock()
mockContext = MockContext()

mockGetQueryInfo(selection = "", order = "", limit = 10, offset = 0)

mockkStatic(MediaLibraryUtils::class)
every { MediaLibraryUtils.putAssetsInfo(any(), any(), any(), any(), any(), any()) } just runs
}

@After
fun tearDown() {
unmockkConstructor(GetQueryInfo::class)
}

@Test
fun `getAssets should resolve with correct response`() {
//arrange
val context = mockContext with mockContentResolverForResult(arrayOf(
MockData.mockImage.toColumnArray(),
MockData.mockVideo.toColumnArray()
))

//act
GetAssets(context, mutableMapOf(), promise).doInBackground()

//assert
promiseResolved(promise) {
assertEquals(2, it.getInt("totalCount"))
}
}

@Test
fun `GetAssets should reject on null cursor`() {
//arrange
val context = mockContext with mockContentResolver(null)

//act
GetAssets(context, mutableMapOf(), promise).doInBackground()

//assert
assertRejectedWithCode(promise, ERROR_UNABLE_TO_LOAD)
}

@Test
fun `GetAssets should reject on SecurityException`() {
//arrange
val context = mockContext with throwableContentResolver(SecurityException())

//act
GetAssets(context, mutableMapOf(), promise).doInBackground()

//assert
assertRejectedWithCode(promise, ERROR_UNABLE_TO_LOAD_PERMISSION)
}

@Test
fun `GetAssets should reject on IOException`() {
//arrange
val context = mockContext with throwableContentResolver(IOException())

//act
GetAssets(context, mutableMapOf(), promise).doInBackground()

//assert
assertRejectedWithCode(promise, ERROR_UNABLE_TO_LOAD)
}

private fun mockGetQueryInfo(selection: String, order: String, limit: Int, offset: Int) {
val mockQueryInfo = mockk<GetQueryInfo>()
every { mockQueryInfo.selection } returns selection
every { mockQueryInfo.order } returns order
every { mockQueryInfo.limit } returns limit
every { mockQueryInfo.offset } returns offset

mockkConstructor(GetQueryInfo::class)
every { anyConstructed<GetQueryInfo>().invoke() } returns mockQueryInfo
}
}
@@ -0,0 +1,102 @@
package expo.modules.medialibrary

import android.provider.MediaStore
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
internal class GetQueryInfoTests {

@Test
fun `test if proper values are handled properly`() {
// arrange
val limit = 21.0
val offset = "37"
val album = "sampleAlbumId"
val mediaTypes = listOf(MediaLibraryConstants.MEDIA_TYPE_PHOTO)
val createdBefore = 6789.0
val createdAfter = 2345.0
val sortBy = listOf(
ArrayList(listOf(MediaLibraryConstants.SORT_BY_DEFAULT, true))
)

val inputMap = mapOf(
"first" to limit,
"after" to offset,
"createdBefore" to createdBefore,
"createdAfter" to createdAfter,
"album" to album,
"mediaType" to mediaTypes,
"sortBy" to sortBy
)

val expectedSelection = "${MediaStore.Images.Media.BUCKET_ID} = $album" +
" AND ${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (${MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE})" +
" AND ${MediaStore.Images.Media.DATE_TAKEN} > ${createdAfter.toLong()}" +
" AND ${MediaStore.Images.Media.DATE_TAKEN} < ${createdBefore.toLong()}"

val expectedOrder = MediaLibraryUtils.mapOrderDescriptor(sortBy)

// act
val queryInfo = GetQueryInfo(inputMap).invoke()

// assert
assertEquals(limit.toInt(), queryInfo.limit)
assertEquals(offset.toInt(), queryInfo.offset)
assertEquals(expectedSelection, queryInfo.selection)
assertEquals(expectedOrder, queryInfo.order)
}

@Test
fun `test if no input gives default values`() {
// arrange
val expectedSelection = "${MediaStore.Files.FileColumns.MEDIA_TYPE} != ${MediaStore.Files.FileColumns.MEDIA_TYPE_NONE}";

// act
val queryInfo = GetQueryInfo(emptyMap()).invoke()

// assert
assertEquals(20, queryInfo.limit)
assertEquals(0, queryInfo.offset)
assertEquals(expectedSelection, queryInfo.selection)
assertEquals(MediaStore.Images.Media.DEFAULT_SORT_ORDER, queryInfo.order)
}

@Test
fun `test if invalid arguments fall back to defaults`() {
// arrange
val limitOutOfRange = -123.0

val inputMap = mapOf(
"first" to limitOutOfRange,
"after" to "invalidStringValue"
)

// act
val queryInfo = GetQueryInfo(inputMap).invoke()

// assert
assertEquals(limitOutOfRange.toInt(), queryInfo.limit)
assertEquals(0, queryInfo.offset)
}

@Test(expected = IllegalArgumentException::class)
fun `test if invalid mediaType throws`() {
val inputMap = mapOf(
"mediaType" to listOf("someRandomString")
)

val queryInfo = GetQueryInfo(inputMap).invoke()
}

@Test(expected = IllegalArgumentException::class)
fun `test if invalid order throws`() {
val inputMap = mapOf(
"sortBy" to listOf("invalid name")
)

val queryInfo = GetQueryInfo(inputMap).invoke()
}
}

0 comments on commit c6f3677

Please sign in to comment.