diff --git a/.circleci/config.yml b/.circleci/config.yml index dc69d373..dad55b42 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -271,8 +271,7 @@ jobs: - run: name: Wake device command: | - adb shell input keyevent - adb shell input keyevent 82 & + adb shell input keyevent 82 - run: name: Run e2e tests diff --git a/README.md b/README.md index 4ae1a08d..4834268c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ getData = async () => { See docs for [api and more examples.](docs/API.md) +## Writing tests + +Using [Jest](https://jestjs.io/) for testing? Make sure to check out [docs on how to integrate it with this module.](./docs/Jest-integration.md) + ## Contribution See the [CONTRIBUTING](CONTRIBUTING.md) file for how to help out. diff --git a/docs/Jest-integration.md b/docs/Jest-integration.md new file mode 100644 index 00000000..79f60bad --- /dev/null +++ b/docs/Jest-integration.md @@ -0,0 +1,87 @@ +# Jest integration + +Async Storage module is tighly coupled with its `NativeModule` part - it needs a running React Native application to work properly. In order to use it in tests, you have to provide its separate implementation. Follow those steps to add a mocked `Async Storage` module. + +## Using Async Storage mock + +You can use one of two ways to provide mocked version of `AsyncStorage`: + +### With __mocks__ directory + +1. In your project root directory, create `__mocks__/@react-native-community` directory. +2. Inside that folder, create `async-storage.js` file. +3. Inside that file, export `Async Storage` mock. + +```javascript +export default from '@react-native-community/async-storage/jest/async-storage-mock' +``` + +### With Jest setup file + +1. In your Jest config (probably in `package.json`) add setup files location: + +```json +"jest": { + "setupFiles": ["./path/to/jestSetupFile.js"] +} +``` + +2. Inside your setup file, set up Async Storage mocking: + +```javascript +import mockAsyncStorage from '@react-native-community/async-storage/jest/async-storage-mock'; + +jest.mock('@react-native-community/async-storage', () => mockAsyncStorage); +``` +## Testing with mock + +Each public method available from `Async Storage` is [a mock function](https://jestjs.io/docs/en/mock-functions), that you can test for certain condition, for example, if `.getItem` has been called with a specific arguments: + +```javascript +it('checks if Async Storage is used', async () => { + await asyncOperationOnAsyncStorage(); + + expect(AsyncStorage.getItem).toBeCalledWith('myKey'); +}) +``` + +## Overriding Mock logic + +You can override mock implementation, by replacing its inner functions: + +```javascript +// somewhere in your configuration files +import AsyncStorageMock from '@react-native-community/async-storage/jest/async-storage-mock'; + +AsyncStorageMock.multiGet = jest.fn(([keys], callback) => { + // do something here to retrieve data + callback([]); +}) + +export default AsyncStorageMock; +``` + +You can [check its implementation](../jest/async-storage-mock.js) to get more insight into methods signatures. + +## Troubleshooting + +### **`SyntaxError: Unexpected token export` in async-storage/lib/index.js** + +**Note:** In React Native 0.60+, all `@react-native-community` packages are transformed by default. + +You need to point Jest to transform this package. You can do so, by adding Async Storage path to `transformIgnorePatterns` setting in Jest's configuration. + + +```json +"jest": { + "transformIgnorePatterns": ["node_modules/(?!(@react-native-community/async-storage/lib))"] +} +``` + +Optionally, you can transform whole scope for `react-native-community` and `react-native`: + +```json +"jest": { + "transformIgnorePatterns": ["node_modules/(?!(@react-native-community|react-native))"] +} +``` \ No newline at end of file diff --git a/example/__tests__/App.js b/example/__tests__/App.js index a79ec3d5..29baabc1 100644 --- a/example/__tests__/App.js +++ b/example/__tests__/App.js @@ -1,15 +1,157 @@ /** * @format - * @lint-ignore-every XPLATJSCOPYRIGHT1 */ +/* eslint-disable no-shadow */ import 'react-native'; -import React from 'react'; -import App from '../App'; -// Note: test renderer must be required after react-native. -import renderer from 'react-test-renderer'; +import AsyncStorage from '@react-native-community/async-storage'; -it('renders correctly', () => { - renderer.create(); +describe('Async Storage mock functionality', () => { + describe('Promise based', () => { + it('can read/write data to/from storage', async () => { + const newData = Math.floor(Math.random() * 1000); + + await AsyncStorage.setItem('key', newData); + + const data = await AsyncStorage.getItem('key'); + + expect(data).toBe(newData); + }); + + it('can clear storage', async () => { + await AsyncStorage.setItem('temp_key', Math.random() * 1000); + + let currentValue = await AsyncStorage.getItem('temp_key'); + + expect(currentValue).not.toBeNull(); + + await AsyncStorage.clear(); + + currentValue = await AsyncStorage.getItem('temp_key'); + + expect(currentValue).toBeNull(); + }); + + it('can clear entries in storage', async () => { + await AsyncStorage.setItem('random1', Math.random() * 1000); + await AsyncStorage.setItem('random2', Math.random() * 1000); + + let data1 = await AsyncStorage.getItem('random1'); + let data2 = await AsyncStorage.getItem('random2'); + + expect(data1).not.toBeNull(); + expect(data2).not.toBeNull(); + + await AsyncStorage.removeItem('random1'); + await AsyncStorage.removeItem('random2'); + data1 = await AsyncStorage.getItem('random1'); + data2 = await AsyncStorage.getItem('random2'); + expect(data2).toBeNull(); + expect(data1).toBeNull(); + }); + + it('can use merge with current data in storage', async () => { + let originalPerson = { + name: 'Jerry', + age: 21, + characteristics: { + hair: 'black', + eyes: 'green', + }, + }; + + await AsyncStorage.setItem('person', JSON.stringify(originalPerson)); + + originalPerson.name = 'Harry'; + originalPerson.characteristics.hair = 'red'; + originalPerson.characteristics.shoeSize = 40; + + await AsyncStorage.mergeItem('person', JSON.stringify(originalPerson)); + + const currentPerson = await AsyncStorage.getItem('person'); + const person = JSON.parse(currentPerson); + + expect(person).toHaveProperty('name', 'Harry'); + expect(person.characteristics).toHaveProperty('hair', 'red'); + expect(person.characteristics).toHaveProperty('shoeSize', 40); + }); + }); + + describe('Callback based', () => { + it('can read/write data to/from storage', done => { + const newData = Math.floor(Math.random() * 1000); + + AsyncStorage.setItem('key', newData, function() { + AsyncStorage.getItem('key', function(_, value) { + expect(value).toBe(newData); + done(); + }).catch(e => done.fail(e)); + }); + }); + it('can clear storage', done => { + AsyncStorage.setItem('temp_key', Math.random() * 1000, () => { + AsyncStorage.getItem('temp_key', (_, currentValue) => { + expect(currentValue).not.toBeNull(); + AsyncStorage.clear(() => { + AsyncStorage.getItem('temp_key', (_, value) => { + expect(value).toBeNull(); + done(); + }).catch(e => done.fail(e)); + }); + }).catch(e => done.fail(e)); + }); + }); + + it('can clear entries in storage', done => { + AsyncStorage.setItem('random1', Math.random() * 1000, () => { + AsyncStorage.setItem('random2', Math.random() * 1000, () => { + AsyncStorage.getItem('random1', (_, data1) => { + AsyncStorage.getItem('random2', (_, data2) => { + expect(data1).not.toBeNull(); + expect(data2).not.toBeNull(); + + AsyncStorage.removeItem('random1', () => { + AsyncStorage.removeItem('random2', () => { + AsyncStorage.getItem('random1', (_, value1) => { + AsyncStorage.getItem('random2', (_, value2) => { + expect(value1).toBeNull(); + expect(value2).toBeNull(); + done(); + }).catch(e => done.fail(e)); + }); + }); + }); + }).catch(e => done.fail(e)); + }); + }); + }); + }); + + it('can use merge with current data in storage', done => { + let originalPerson = { + name: 'Jerry', + age: 21, + characteristics: { + hair: 'black', + eyes: 'green', + }, + }; + + AsyncStorage.setItem('person', JSON.stringify(originalPerson), () => { + originalPerson.name = 'Harry'; + originalPerson.characteristics.hair = 'red'; + originalPerson.characteristics.shoeSize = 40; + AsyncStorage.mergeItem('person', JSON.stringify(originalPerson), () => { + AsyncStorage.getItem('person', (_, currentPerson) => { + const person = JSON.parse(currentPerson); + expect(person).toHaveProperty('name', 'Harry'); + expect(person.characteristics).toHaveProperty('hair', 'red'); + expect(person.characteristics).toHaveProperty('shoeSize', 40); + done(); + }).catch(e => done.fail(e)); + }); + }); + }); + }); }); diff --git a/example/e2e/asyncstorage.spec.js b/example/e2e/asyncstorage.e2e.js similarity index 100% rename from example/e2e/asyncstorage.spec.js rename to example/e2e/asyncstorage.e2e.js diff --git a/example/e2e/config.json b/example/e2e/config.json index 38036d30..3491692a 100644 --- a/example/e2e/config.json +++ b/example/e2e/config.json @@ -1,4 +1,5 @@ { "setupFilesAfterEnv": ["./init.js"], - "testEnvironment": "node" + "testEnvironment": "node", + "testMatch": [ "**/?(*.)+(e2e).[jt]s?(x)" ] } \ No newline at end of file diff --git a/example/jest.setup.js b/example/jest.setup.js new file mode 100644 index 00000000..79683a45 --- /dev/null +++ b/example/jest.setup.js @@ -0,0 +1,7 @@ +/** + * @format + */ + +import mockAsyncStorage from '../jest/async-storage-mock'; + +jest.mock('@react-native-community/async-storage', () => mockAsyncStorage); diff --git a/jest/async-storage-mock.js b/jest/async-storage-mock.js new file mode 100644 index 00000000..9a45dd2e --- /dev/null +++ b/jest/async-storage-mock.js @@ -0,0 +1,130 @@ +/** + * @format + * @flow + */ + +type KeysType = Array; +type KeyValueType = Array>; +type CallbackType = ((?Error) => void) | void; +type ItemGetCallbackType = (?Error, ?string) => void; +type ResultCallbackType = ((?Error, ?KeyValueType) => void) | void; + +const asMock = { + __INTERNAL_MOCK_STORAGE__: {}, + + setItem: jest.fn<[string, string, CallbackType], Promise<*>>( + async (key: string, value: string, callback: CallbackType) => { + const setResult = await asMock.multiSet([[key, value]], undefined); + + callback && callback(setResult); + return setResult; + }, + ), + getItem: jest.fn<[string, ItemGetCallbackType], Promise<*>>( + async (key: string, callback: ItemGetCallbackType) => { + const getResult = await asMock.multiGet([key], undefined); + + const result = getResult[0] ? getResult[0][1] : null; + + callback && callback(null, result); + return result; + }, + ), + removeItem: jest.fn<[string, CallbackType], Promise>( + (key: string, callback: CallbackType) => + asMock.multiRemove([key], callback), + ), + mergeItem: jest.fn<[string, string, CallbackType], Promise<*>>( + (key: string, value: string, callback: CallbackType) => + asMock.multiMerge([[key, value]], callback), + ), + + clear: jest.fn<[CallbackType], Promise<*>>(_clear), + getAllKeys: jest.fn<[], void>(), + flushGetRequests: jest.fn<[], void>(), + + multiGet: jest.fn<[KeysType, ResultCallbackType], Promise<*>>(_multiGet), + multiSet: jest.fn<[KeyValueType, CallbackType], Promise<*>>(_multiSet), + multiRemove: jest.fn<[KeysType, CallbackType], Promise<*>>(_multiRemove), + multiMerge: jest.fn<[KeyValueType, CallbackType], Promise<*>>(_multiMerge), +}; + +async function _multiSet(keyValuePairs: KeyValueType, callback: CallbackType) { + keyValuePairs.forEach(keyValue => { + const key = keyValue[0]; + const value = keyValue[1]; + + asMock.__INTERNAL_MOCK_STORAGE__[key] = value; + }); + callback && callback(null); + return null; +} + +async function _multiGet(keys: KeysType, callback: ResultCallbackType) { + const values = keys.map(key => [ + key, + asMock.__INTERNAL_MOCK_STORAGE__[key] || null, + ]); + callback && callback(null, values); + + return values; +} + +async function _multiRemove(keys: KeysType, callback: CallbackType) { + keys.forEach(key => { + if (asMock.__INTERNAL_MOCK_STORAGE__[key]) { + delete asMock.__INTERNAL_MOCK_STORAGE__[key]; + } + }); + + callback && callback(null); + return null; +} + +async function _clear(callback: CallbackType) { + asMock.__INTERNAL_MOCK_STORAGE__ = {}; + + callback && callback(null); + + return null; +} + +async function _multiMerge( + keyValuePairs: KeyValueType, + callback: CallbackType, +) { + keyValuePairs.forEach(keyValue => { + const key = keyValue[0]; + const value = JSON.parse(keyValue[1]); + + const oldValue = JSON.parse(asMock.__INTERNAL_MOCK_STORAGE__[key]); + + const processedValue = JSON.stringify(_deepMergeInto(oldValue, value)); + + asMock.__INTERNAL_MOCK_STORAGE__[key] = processedValue; + }); + + callback && callback(null); + return null; +} + +const _isObject = obj => typeof obj === 'object' && !Array.isArray(obj); +const _deepMergeInto = (oldObject, newObject) => { + const newKeys = Object.keys(newObject); + const mergedObject = oldObject; + + newKeys.forEach(key => { + const oldValue = mergedObject[key]; + const newValue = newObject[key]; + + if (_isObject(oldValue) && _isObject(newValue)) { + mergedObject[key] = _deepMergeInto(oldValue, newValue); + } else { + mergedObject[key] = newValue; + } + }); + + return mergedObject; +}; + +export default asMock; diff --git a/lib/AsyncStorage.js b/lib/AsyncStorage.js index b3298ae7..48e52828 100644 --- a/lib/AsyncStorage.js +++ b/lib/AsyncStorage.js @@ -17,14 +17,20 @@ const RCTAsyncStorage = NativeModules.RNC_AsyncSQLiteDBStorage || NativeModules.RNCAsyncStorage; if (!RCTAsyncStorage) { - throw new Error(`@RNCommunity/AsyncStorage: NativeModule.RCTAsyncStorage is null. + throw new Error(`[@RNC/AsyncStorage]: NativeModule: AsyncStorage is null. To fix this issue try these steps: + • Run \`react-native link @react-native-community/async-storage\` in the project root. - • Rebuild and re-run the app. - • Restart the packager with \`--clearCache\` flag. + + • Rebuild and restart the app. + + • Run the packager with \`--clearCache\` flag. + • If you are using CocoaPods on iOS, run \`pod install\` in the \`ios\` directory and then rebuild and re-run the app. + • If this happens while testing with Jest, check out docs how to integrate AsyncStorage with it: https://github.com/react-native-community/react-native-async-storage/blob/master/docs/Jest-integration.md + If none of these fix the issue, please open an issue on the Github repository: https://github.com/react-native-community/react-native-async-storage/issues `); } diff --git a/lib/hooks.js b/lib/hooks.js index 3508c8df..adb1d03f 100644 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -1,3 +1,7 @@ +/** + * @format + */ + import AsyncStorage from './AsyncStorage'; type AsyncStorageHook = { diff --git a/lib/index.js b/lib/index.js index b1752582..bd38f085 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,2 +1,8 @@ -export default from './AsyncStorage'; -export { useAsyncStorage } from './hooks'; +/** + * @format + */ + +import AsyncStorage from './AsyncStorage'; + +export default AsyncStorage; +export {useAsyncStorage} from './hooks'; diff --git a/package.json b/package.json index 661378b0..ceffac91 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "semantic-release": "15.13.3" }, "jest": { - "preset": "react-native" + "preset": "react-native", + "setupFiles": ["./example/jest.setup.js"] }, "detox": { "test-runner": "jest",