Skip to content

Commit

Permalink
test(apps/mobile-e2e): add E2E tests covering functionality as define…
Browse files Browse the repository at this point in the history
…d in the spec
  • Loading branch information
hassankhan committed Oct 14, 2024
1 parent 1c1b5de commit acd6e1b
Show file tree
Hide file tree
Showing 19 changed files with 190 additions and 35 deletions.
2 changes: 1 addition & 1 deletion apps/mobile-e2e/.detoxrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"simulator": {
"type": "ios.simulator",
"device": {
"type": "iPhone 15 Plus"
"type": "iPhone 16"
}
},
"emulator": {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile-e2e/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CAT_API_KEY=
Binary file added apps/mobile-e2e/src/__fixtures__/image.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
102 changes: 84 additions & 18 deletions apps/mobile-e2e/src/app/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,97 @@
import { device, element, by, expect } from 'detox';
import { deleteImage, uploadImage } from '../utils/api';

describe('KittyGram home screen', () => {
const getSystemDialog = (label: string) => {
if (device.getPlatform() === 'ios') {
return element(by.label(label)).atIndex(0);
}
return element(by.text(label));
};

describe('KittyGram home screen with a fresh account', () => {
beforeEach(async () => {
await device.reloadReactNative();
});

// it('should be able to upload a photo', () => {
//
// });
it('should show the empty list with no images message', async () => {
await expect(element(by.id('NoData<CatCard>'))).toBeVisible();
});

it.skip('should be able to upload a photo', () => {
// This test is skipped because it requires relatively extensive system
// dialog handling which is not yet supported by Detox on Android.
// See https://wix.github.io/Detox/docs/api/system/#bysystemlabellabel
});

it('should be able to remove a photo', async () => {
const image = await uploadImage();
await device.reloadReactNative();

const deleteButton = element(by.id(`Card.Button<Delete>.${image.id}`));
await expect(deleteButton).toBeVisible();
await deleteButton.tap();

await getSystemDialog('Confirm').tap();

await expect(element(by.id('NoData<CatCard>'))).toBeVisible();
});
});

describe('KittyGram home screen with an existing account', () => {
let image;

beforeEach(async () => {
image = await uploadImage();
await device.reloadReactNative();
});

afterEach(async () => {
await deleteImage(image.id);
});

it('should display a single image', async () => {
await expect(element(by.id('List<CatCard>'))).toBeVisible();
});

it('should be able to upvote a photo', async () => {
const upvoteButton = element(by.id(`Card.Button<Upvote>.${image.id}`));

await expect(upvoteButton).toBeVisible();
await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('0');

await upvoteButton.tap();
await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('1');
});

it('should be able to downvote a photo', async () => {
const downvoteButton = element(by.id(`Card.Button<Downvote>.${image.id}`));

await expect(downvoteButton).toBeVisible();
await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('0');

await downvoteButton.tap();
await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('-1');
});

it('should be able to favourite and unfavourite a photo', async () => {
const favouriteButton = element(
by.id(`Card.Button<Favourite>.${image.id}`)
);
const unfavouriteButton = element(
by.id(`Card.Button<Unfavourite>.${image.id}`)
);

// it('should be able to remove a photo', () => {
//
// });
await expect(favouriteButton).toBeVisible();
await expect(unfavouriteButton).not.toBeVisible();

// it('should be able to upvote a photo', () => {
//
// });
await favouriteButton.tap();

// it('should be able to downvote a photo', () => {
//
// });
await expect(favouriteButton).not.toBeVisible();
await expect(unfavouriteButton).toBeVisible();

// it('should be able to favourite a photo', () => {
//
// });
await unfavouriteButton.tap();

it('should display welcome message', async () => {
await expect(element(by.id('heading'))).toHaveText('Welcome Mobile 👋');
await expect(favouriteButton).toBeVisible();
await expect(unfavouriteButton).not.toBeVisible();
});
});
53 changes: 53 additions & 0 deletions apps/mobile-e2e/src/utils/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { getFixture } from './fixtures';

const getUrl = (endpoint: string) => `https://api.thecatapi.com/v1/${endpoint}`;
const getOptions = (options: RequestInit) => ({
...options,
headers: {
'x-api-key': process.env.CAT_API_KEY,
...options.headers,
},
});

const makeRequest = async (endpoint: string, options: RequestInit) => {
const r = await fetch(getUrl(endpoint), getOptions(options));

if (r.ok && options.method === 'DELETE') {
return;
}

if (r.ok) {
return r.json();
}

const error = await r.text();
console.error(error);
throw new Error(error);
};

export const listImages = async () => {
return makeRequest('images?limit=10', {
method: 'GET',
});
};

export const uploadImage = async () => {
const fileName = 'image.jpg';
const image = await getFixture(fileName);
const body = new FormData();
body.append('file', image, fileName);

return makeRequest('images/upload', {
method: 'POST',
headers: {
ContentType: 'multipart/form-data',
},
body,
});
};

export const deleteImage = async (imageId: string) => {
return makeRequest(`images/${imageId}`, {
method: 'DELETE',
});
};
11 changes: 11 additions & 0 deletions apps/mobile-e2e/src/utils/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as path from 'node:path';
import { openAsBlob } from 'node:fs';
import { lookup } from 'mime-types';

const fixturesPath = path.resolve(__dirname, '../__fixtures__');

export const getFixture = async (fixtureName: string) => {
const resolvedPath = path.resolve(fixturesPath, fixtureName);
const mimeType = lookup(resolvedPath) || 'image/jpg';
return openAsBlob(resolvedPath, { type: mimeType });
};
11 changes: 7 additions & 4 deletions apps/mobile/src/components/CatButton/CatButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import Animated, {
import { useCallback, useEffect } from 'react';

type CatButtonProps = {
animated?: boolean;
onPress: ((event: GestureResponderEvent) => void) | null | undefined;
};

export const CatButton = ({ onPress }: CatButtonProps) => {
export const CatButton = ({ animated, onPress }: CatButtonProps) => {
const xVal = useSharedValue(-1);
const yVal = useSharedValue(0);
const blinkVal = useSharedValue(1);
Expand Down Expand Up @@ -93,8 +94,8 @@ export const CatButton = ({ onPress }: CatButtonProps) => {
}, [blinkVal, boopVal, idle, onPress, xVal, yVal]);

useEffect(() => {
idle();
}, [idle]);
animated && idle();
}, [animated, idle]);

const noseStyle = useAnimatedStyle(() => {
return {
Expand Down Expand Up @@ -145,8 +146,10 @@ export const CatButton = ({ onPress }: CatButtonProps) => {
};
});

const pressAction = animated ? boop : onPress;

return (
<Pressable onPress={boop}>
<Pressable onPress={pressAction}>
<View
style={{
flex: 1,
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/components/UploadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { Pressable, StyleProp, ViewStyle } from 'react-native';
import { createStyleSheet, useStyles } from 'react-native-unistyles';
import { CatButton } from './CatButton/CatButton';

const isTestMode = (process.env.EXPO_PUBLIC_TEST_MODE ?? 'false') === 'true';

export type UploadButtonProps = ComponentProps<typeof Pressable>;

export const UploadButton = ({ style, ...props }: UploadButtonProps) => {
const { styles } = useStyles(stylesheet);

return (
<Pressable style={[styles.root, style as StyleProp<ViewStyle>]} {...props}>
<CatButton onPress={props.onPress} />
{/* Don't animate in test mode because it causes Detox to hang */}
<CatButton animated={!isTestMode} onPress={props.onPress} />
</Pressable>
);
};
Expand Down
17 changes: 11 additions & 6 deletions apps/mobile/src/features/CatCard/CardActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,25 @@ export const CardActions = ({ item }: CardProps) => {

const score = useMemo(() => {
const votes = item.votes || [];
const scoreInteger = votes.reduce((acc, curr) => acc + curr.value, 0);

const scorePrefix = match(scoreInteger)
return votes.reduce((acc, curr) => acc + curr.value, 0);
}, [item.votes]);
const prefix = useMemo(() => {
return match(score)
.with(P.number.positive(), () => '😼')
.with(P.number.negative(), () => '😾')
.otherwise(() => '🐱');
return `${scorePrefix} ${scoreInteger}`;
}, [item.votes]);
}, [score]);

return (
<View style={styles.root}>
<View style={styles.inner}>
<UpvoteButton item={item} />
<Text style={styles.voteCount}>{score}</Text>
<Text testID={`Card.ScorePrefix.${item.id}`} style={styles.voteCount}>
{prefix}
</Text>
<Text testID={`Card.Score.${item.id}`} style={styles.voteCount}>
{score}
</Text>
<DownvoteButton item={item} />
</View>
</View>
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/CatCard/CatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const CatCard = ({ item }: CardProps) => {
const { styles } = useStyles(stylesheet);

return (
<View style={styles.root}>
<View style={styles.root} testID={`Card.${item.id}`}>
<Image style={styles.image} source={item.url} />
<ImageOverlay item={item} />

Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/CatCard/DeleteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ export const DeleteButton = ({ item }: CardProps) => {
style: 'cancel',
},
{
text: 'Delete',
text: 'Confirm',
onPress: () => deleteMutationFn(item.id),
},
]);
}, [deleteMutationFn, item.id]);

return (
<IconButton
testID={`Card.Button<Delete>.${item.id}`}
rounded
disabled={isLoading}
iconProps={{ name: 'trash', size: 24 }}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/CatCard/DownvoteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const DownvoteButton = ({ item }: CardProps) => {
}, [item.id, downvoteMutationFn]);
return (
<IconButton
testID={`Card.Button<Downvote>.${item.id}`}
disabled={isLoading}
iconProps={{ name: 'thumbsdown', size: 24, color: 'red' }}
onPress={handlePress}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/CatCard/FavouriteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
} from '../../store/services/CatApi';
import { CardProps } from './shared';

const getTestId = (isFavourite: boolean, id: string) =>
`Card.Button<${isFavourite ? 'Unfavourite' : 'Favourite'}>.${id}`;

export const FavouriteButton = ({ item }: CardProps) => {
const [favouriteMutationFn, { isLoading: isFavouriteLoading }] =
useFavouriteImageMutation();
Expand All @@ -21,6 +24,7 @@ export const FavouriteButton = ({ item }: CardProps) => {

return (
<IconButton
testID={getTestId(Boolean(item.favourite), item.id)}
rounded
disabled={isLoading}
iconProps={{
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/CatCard/UpvoteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const UpvoteButton = ({ item }: CardProps) => {

return (
<IconButton
testID={`Card.Button<Upvote>.${item.id}`}
disabled={isLoading}
iconProps={{ name: 'thumbsup', size: 24, color: 'green' }}
onPress={handlePress}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/HomePage/ImageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const ImageList = () => {
return (
<View style={styles.root}>
<Animated.FlatList
testID={`List<CatCard>`}
// style={styles.list}
itemLayoutAnimation={LinearTransition}
contentContainerStyle={styles.list}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/HomePage/NoImagesFound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createStyleSheet, useStyles } from 'react-native-unistyles';
export const NoImagesFound = () => {
const { styles } = useStyles(stylesheet);
return (
<View style={styles.root}>
<View style={styles.root} testID={`NoData<CatCard>`}>
<View style={styles.message}>
<FontAwesome name="warning" size={24} color="grey" />
<Text>No images found</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ export const ImageSourceButton = ({
}
}, [dispatch, onComplete, onImageSelect, thunk, uploadImageFn]);

const displaySource = upperCaseFirstLetter(source);

return (
<ImageSource
testID={`Sheet<Upload>.${displaySource}`}
disabled={isLoading}
icon={icon}
label={upperCaseFirstLetter(source)}
label={displaySource}
onPress={handleSelectAndUpload}
/>
);
Expand Down
Binary file modified bun.lockb
Binary file not shown.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"@testing-library/jest-native": "~5.4.3",
"@testing-library/react-native": "~12.5.0",
"@types/jest": "^29.5.12",
"@types/node": "18.16.9",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.7.5",
"@types/react": "~18.2.45",
"babel-jest": "^29.7.0",
"babel-preset-expo": "~11.0.0",
Expand All @@ -66,6 +67,7 @@
"jest-circus": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-expo": "~51.0.2",
"mime-types": "^2.1.35",
"nx": "20.0.0",
"prettier": "^2.6.2",
"react-test-renderer": "18.2.0",
Expand Down

0 comments on commit acd6e1b

Please sign in to comment.