-
-
Notifications
You must be signed in to change notification settings - Fork 639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add tests for ZulipAsyncStorage.getItem
and .setItem
.
#4709
Changes from all commits
96b796b
58bbee6
f9bbdd0
b35aeb9
21f12c8
d945e45
e91d9a8
f46368f
317e9ea
3737f61
a81847f
9fff76d
cd29cb9
df29833
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
/* @flow strict-local */ | ||
import { Platform, NativeModules } from 'react-native'; | ||
import AsyncStorage from '@react-native-community/async-storage'; | ||
import ZulipAsyncStorage from '../ZulipAsyncStorage'; | ||
import * as logging from '../../utils/logging'; | ||
import * as eg from '../../__tests__/lib/exampleData'; | ||
|
||
describe('setItem', () => { | ||
const key = 'foo!'; | ||
const value = '123!'; | ||
|
||
// For checking that AsyncStorage.setItem is called in ways we expect. | ||
const asyncStorageSetItemSpy = jest.spyOn(AsyncStorage, 'setItem'); | ||
beforeEach(() => asyncStorageSetItemSpy.mockClear()); | ||
|
||
const run = async () => ZulipAsyncStorage.setItem(key, value); | ||
|
||
describe('success', () => { | ||
// AsyncStorage provides its own mock for `.setItem`, which gives | ||
// success every time. So, no need to mock that behavior | ||
// ourselves. | ||
|
||
test('resolves correctly', async () => { | ||
await expect(run()).resolves.toBe(null); | ||
}); | ||
|
||
test('AsyncStorage.setItem called correctly', async () => { | ||
await run(); | ||
expect(asyncStorageSetItemSpy).toHaveBeenCalledTimes(1); | ||
expect(asyncStorageSetItemSpy).toHaveBeenCalledWith( | ||
key, | ||
Platform.OS === 'ios' ? value : await NativeModules.TextCompressionModule.compress(value), | ||
); | ||
}); | ||
}); | ||
|
||
describe('failure', () => { | ||
// AsyncStorage provides its own mock for `.setItem`, but it's | ||
// not set up to simulate failure. So, mock that behavior | ||
// ourselves, and reset to the global mock when we're done. | ||
const globalMock = AsyncStorage.setItem; | ||
beforeEach(() => { | ||
// $FlowFixMe[cannot-write] Make Flow understand about mocking. | ||
AsyncStorage.setItem = jest.fn(async (k: string, v: string): Promise<null> => { | ||
throw new Error(); | ||
}); | ||
}); | ||
afterAll(() => { | ||
// $FlowFixMe[cannot-write] Make Flow understand about mocking. | ||
AsyncStorage.setItem = globalMock; | ||
}); | ||
|
||
test('rejects correctly', async () => { | ||
await expect(run()).rejects.toThrow(Error); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('getItem', () => { | ||
const key = 'foo!'; | ||
const value = '123!'; | ||
|
||
// For checking that AsyncStorage.getItem is called in ways we expect. | ||
const asyncStorageGetItemSpy = jest.spyOn(AsyncStorage, 'getItem'); | ||
beforeEach(() => asyncStorageGetItemSpy.mockClear()); | ||
|
||
beforeAll(async () => { | ||
// `AsyncStorage` mocks storage by writing to a variable instead | ||
// of to the disk. Put something there for our | ||
// `ZulipAsyncStorage.getItem` to retrieve. | ||
await AsyncStorage.setItem( | ||
key, | ||
Platform.OS === 'ios' ? value : await NativeModules.TextCompressionModule.compress(value), | ||
); | ||
|
||
// suppress `logging.error` output | ||
// $FlowFixMe - teach Flow about mocks | ||
logging.error.mockReturnValue(); | ||
}); | ||
|
||
const run = async () => ZulipAsyncStorage.getItem(key); | ||
|
||
describe('success', () => { | ||
// AsyncStorage provides its own mock for `.getItem`, which gives | ||
// success every time. So, no need to mock that behavior | ||
// ourselves. | ||
|
||
test('calls AsyncStorage.getItem as we expect it to', async () => { | ||
await run(); | ||
expect(asyncStorageGetItemSpy).toHaveBeenCalledTimes(1); | ||
expect(asyncStorageGetItemSpy).toHaveBeenCalledWith(key); | ||
}); | ||
|
||
test('resolves correctly', async () => { | ||
await expect(run()).resolves.toBe(value); | ||
}); | ||
}); | ||
|
||
describe('failure', () => { | ||
// AsyncStorage provides its own mock for `.getItem`, but it's | ||
// not set up to simulate failure. So, mock that behavior | ||
// ourselves. | ||
const globalMock = AsyncStorage.getItem; | ||
beforeEach(() => { | ||
// $FlowFixMe[cannot-write] Make Flow understand about mocking. | ||
AsyncStorage.getItem = jest.fn(async (k: string): Promise<string | null> => { | ||
throw new Error(); | ||
}); | ||
}); | ||
afterAll(() => { | ||
// $FlowFixMe[cannot-write] Make Flow understand about mocking. | ||
AsyncStorage.getItem = globalMock; | ||
}); | ||
|
||
test('rejects correctly', async () => { | ||
await expect(run()).rejects.toThrow(Error); | ||
}); | ||
}); | ||
|
||
if (Platform.OS === 'android') { | ||
describe('android', () => { | ||
test('panics when value is in unknown compression format', async () => { | ||
const unknownHeader = 'z|mock-unknown-format|'; | ||
|
||
await AsyncStorage.setItem( | ||
`${key}-unknown`, | ||
`${unknownHeader}${Buffer.from('123!').toString('hex')}`, | ||
); | ||
|
||
await expect(ZulipAsyncStorage.getItem(`${key}-unknown`)).rejects.toThrow( | ||
`No decompression module found for format ${unknownHeader}`, | ||
); | ||
}); | ||
}); | ||
} | ||
}); | ||
|
||
describe('setItem/getItem together', () => { | ||
test('round-tripping works', async () => { | ||
const key = eg.randString(); | ||
const value = eg.randString(); | ||
|
||
// AsyncStorage provides its own mocks for `.getItem` and | ||
// `.setItem`; it writes to a variable instead of storage. | ||
await ZulipAsyncStorage.setItem(key, value); | ||
expect(await ZulipAsyncStorage.getItem(key)).toEqual(value); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
/* @flow strict-local */ | ||
import invariant from 'invariant'; | ||
|
||
import * as eg from '../../__tests__/lib/exampleData'; | ||
import objectEntries from '../../utils/objectEntries'; | ||
import type { GlobalState } from '../../types'; | ||
import ZulipAsyncStorage from '../ZulipAsyncStorage'; | ||
import { stringify, parse } from '../replaceRevive'; | ||
|
||
const getRoundTrippedStateValue = async <K: $Keys<GlobalState>, V: $Values<GlobalState>>( | ||
key: K, | ||
value: V, | ||
): Promise<V> => { | ||
// 1: Serialize a "live" object; e.g., a ZulipVersion instance. | ||
const stringifiedValue = stringify(value); | ||
|
||
// 2: Write to storage. Parts of this are mocked; so, stress-test | ||
// those mocks: | ||
// - Compression via TextCompressionModule on Android | ||
// - AsyncStorage: the library provides a mock implementation | ||
// that writes to a variable instead of to the disk | ||
await ZulipAsyncStorage.setItem(key, stringifiedValue); | ||
|
||
// 3: Read from storage. Parts of this are mocked; so, stress-test | ||
// those mocks: | ||
// - Decompression via TextCompressionModule on Android | ||
// - AsyncStorage: the library provides a mock implementation | ||
// that reads from a variable instead of from the disk | ||
const valueFromStorage = await ZulipAsyncStorage.getItem(key); | ||
invariant(valueFromStorage != null, 'valueFromStorage is not null/undefined'); | ||
|
||
// 4: "revive", e.g., a ZulipVersion instance. | ||
const parsedValueFromStorage = parse(valueFromStorage); | ||
|
||
// $FlowIgnore[incompatible-cast] | ||
return (parsedValueFromStorage: V); | ||
}; | ||
|
||
const getRoundTrippedState = async (globalState: GlobalState): Promise<GlobalState> => { | ||
const entries = await Promise.all( | ||
objectEntries(globalState).map( | ||
// eslint-disable-next-line flowtype/generic-spacing | ||
async <K: $Keys<GlobalState>, V: $Values<GlobalState>>([key: K, value: V]): Promise< | ||
[K, V], | ||
> => [ | ||
// $FlowIgnore[incompatible-cast] | ||
(key: K), | ||
// $FlowIgnore[incompatible-cast] | ||
(await getRoundTrippedStateValue(key, value): V), | ||
], | ||
), | ||
); | ||
// $FlowIgnore[incompatible-indexer] | ||
return Object.fromEntries(entries); | ||
}; | ||
|
||
/** | ||
* Test several pieces of the Redux storage logic at once. | ||
* | ||
* We're counting on a lot of our Redux state to round-trip through | ||
* storage. Jest can't realistically test everything in this process | ||
* -- in particular, the storage itself, and any native code involved | ||
* -- but, in the places where it can't, at least it can stress-test | ||
* the mocks we've written and use elsewhere, to make sure they're | ||
* reasonably faithful. | ||
* | ||
* `eg.plusReduxState` represents standard example data, though not | ||
* the entire range of possible data; see its jsdoc. Still, we'll want | ||
* to know about any failures to round-trip that arise from changes to | ||
* that. | ||
*/ | ||
test('`eg.plusReduxState` round-trips through storage', async () => { | ||
const stateBefore = eg.plusReduxState; | ||
// The `GlobalState` annotation seems to guide Flow to better error | ||
// messages; without it, Flow seems to get confused by the `await`. | ||
const stateAfter: GlobalState = await getRoundTrippedState(stateBefore); | ||
|
||
// `AvatarURL` instances are generally lazy with their `new URL` | ||
// computations; they have a stateful piece that might be a string | ||
// or a `URL` object. We could figure out how to teach Jest that | ||
// that difference doesn't matter, but for now, just normalize both | ||
// states by "waking up" their `AvatarURL`s. | ||
[stateBefore, stateAfter].forEach(s => { | ||
s.users.forEach(u => u.avatar_url.get(100)); | ||
s.messages.forEach(m => m.avatar_url.get(100)); | ||
}); | ||
|
||
expect(stateAfter).toEqual(stateBefore); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -52,7 +52,12 @@ export default function MainTabsScreen(props: Props) { | |
<OfflineNotice /> | ||
<Tab.Navigator | ||
{...bottomTabNavigatorConfig({ | ||
showLabel: !!Platform.isPad, | ||
// TODO: Find a way to tell if we're on an Android tablet, | ||
// and use that -- we don't want to assume Android users | ||
// aren't on tablets, but `isPad` is iOS only and `Platform` | ||
// doesn't have something else for Android (yet): | ||
// https://reactnative.dev/docs/platform#ispad-ios | ||
showLabel: Platform.OS === 'ios' && Platform.isPad, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's unclear to me that this is an actual improvement — this makes it look like it's intentional that this is iOS only, whereas I think the reality is we just don't have a good way of detecting Android tablets, but if we could, we'd like them to have labels, right? I don't have a strong feeling on this, but this seems odd to me. I guess maybe your comment is hinting at needing the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That's right, yeah. Yeah; this could be made clearer—I hopped over here from #4750 (comment) to add to the "flow config: Stop ignoring Android-only JS files" commit, since I realized that that commit might be a good place to add the Specifically, after adding the
I think the linked doc is clear that |
||
showIcon: true, | ||
})} | ||
backBehavior="none" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice to clean this up 🙂