Merge pull request #429 from gianantoniopini/develop

Addition of some unit tests to Builder and Dashboard pages
This commit is contained in:
Amruth Pillai 2021-01-23 19:19:06 +05:30 committed by GitHub
commit 6c31d3dff3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 381 additions and 103 deletions

View File

@ -6,10 +6,6 @@ import FirebaseStub, {
describe('FirebaseStub', () => {
describe('auth', () => {
afterEach(() => {
FirebaseStub.auth().dispose();
});
it('reuses existing Auth instance', () => {
const auth1 = FirebaseStub.auth();
const auth2 = FirebaseStub.auth();
@ -49,14 +45,15 @@ describe('FirebaseStub', () => {
const observer = () => {};
const unsubscribe = FirebaseStub.auth().onAuthStateChanged(observer);
expect(unsubscribe).toBeTruthy();
expect(FirebaseStub.auth().onAuthStateChangedObservers).toHaveLength(1);
expect(FirebaseStub.auth().onAuthStateChangedObservers[0]).toEqual(
observer,
);
expect(
FirebaseStub.auth().onAuthStateChangedObservers.indexOf(observer),
).toBeGreaterThanOrEqual(0);
unsubscribe();
expect(FirebaseStub.auth().onAuthStateChangedObservers).toHaveLength(0);
expect(
FirebaseStub.auth().onAuthStateChangedObservers.indexOf(observer),
).not.toBeGreaterThanOrEqual(0);
});
});
@ -258,7 +255,7 @@ describe('FirebaseStub', () => {
);
});
it('previously set query parameters are not kept when retrieving reference again', async () => {
it('previously set query parameters are not kept when retrieving reference again', () => {
let reference = null;
reference = FirebaseStub.database().ref(DatabaseConstants.resumesPath);

View File

@ -2,6 +2,7 @@
import { v4 as uuidv4 } from 'uuid';
import Constants from '../constants/auth';
import delay from '../../utils/index';
const singleton = Symbol('');
const singletonEnforcer = Symbol('');
@ -32,10 +33,6 @@ class Auth {
return this._onAuthStateChangedObservers;
}
dispose() {
this._onAuthStateChangedObservers = [];
}
onAuthStateChanged(observer) {
this.onAuthStateChangedObservers.push(observer);
@ -49,6 +46,8 @@ class Auth {
async signInAnonymously() {
const user = Constants.anonymousUser1;
await delay(Constants.defaultDelayInMilliseconds);
this.onAuthStateChangedObservers.forEach((observer) => observer(user));
return Promise.resolve(user);

View File

@ -14,6 +14,8 @@ const anonymousUser2 = {
uid: 'anonym456',
};
const defaultDelayInMilliseconds = 100;
class Auth {
static get anonymousUser1() {
return anonymousUser1;
@ -22,6 +24,10 @@ class Auth {
static get anonymousUser2() {
return anonymousUser2;
}
static get defaultDelayInMilliseconds() {
return defaultDelayInMilliseconds;
}
}
export default Auth;

View File

@ -20,6 +20,8 @@ const user2 = {
isAnonymous: AuthConstants.anonymousUser2.isAnonymous,
};
const defaultDelayInMilliseconds = 100;
class Database {
static get valueEventType() {
return valueEventType;
@ -60,6 +62,10 @@ class Database {
static get user2() {
return user2;
}
static get defaultDelayInMilliseconds() {
return defaultDelayInMilliseconds;
}
}
export default Database;

View File

@ -1,9 +1,9 @@
/* eslint-disable no-underscore-dangle */
import { v4 as uuidv4 } from 'uuid';
import { debounce } from 'lodash';
import DatabaseConstants from '../constants/database';
import DataSnapshot from './dataSnapshot';
import delay from '../../utils/index';
const parsePath = (path) => {
if (!path) {
@ -147,11 +147,7 @@ class Reference {
? this._dataSnapshot
: new DataSnapshot(() => this._getData(), snapshotValue);
const debouncedEventCallback = debounce(
this.eventCallbacks[eventType],
100,
);
debouncedEventCallback(snapshot);
this.eventCallbacks[eventType](snapshot);
}
equalTo(value) {
@ -172,8 +168,12 @@ class Reference {
this.eventCallbacks[eventType] = callback;
if (eventType === DatabaseConstants.valueEventType) {
this.triggerEventCallback(eventType);
setTimeout(() => {
this.triggerEventCallback(eventType);
}, DatabaseConstants.defaultDelayInMilliseconds);
}
return callback;
}
async once(eventType) {
@ -183,6 +183,8 @@ class Reference {
throw new Error('eventType should be a string.');
}
await delay(DatabaseConstants.defaultDelayInMilliseconds);
return Promise.resolve(this._dataSnapshot);
}
@ -192,21 +194,27 @@ class Reference {
}
async update(value) {
await delay(DatabaseConstants.defaultDelayInMilliseconds);
this._handleDataUpdate(value);
return Promise.resolve(true);
return Promise.resolve();
}
async remove() {
await delay(DatabaseConstants.defaultDelayInMilliseconds);
this._handleDataUpdate(null);
return Promise.resolve(true);
return Promise.resolve();
}
async set(value) {
await delay(DatabaseConstants.defaultDelayInMilliseconds);
this._handleDataUpdate(value);
return Promise.resolve(true);
return Promise.resolve();
}
}

View File

@ -1,5 +1,7 @@
import React from 'react';
import delay from './utils/index';
const Gatsby = jest.requireActual('gatsby');
const fluidImageShapes = [
@ -67,6 +69,14 @@ const useStaticQuery = () => ({
},
});
const defaultDelayInMilliseconds = 100;
const navigate = async () => {
await delay(defaultDelayInMilliseconds);
return Promise.resolve();
};
module.exports = {
...Gatsby,
graphql: jest.fn(),
@ -76,5 +86,6 @@ module.exports = {
href: to,
}),
),
navigate: jest.fn(navigate),
useStaticQuery,
};

5
__mocks__/utils/index.js Normal file
View File

@ -0,0 +1,5 @@
const delay = async (milliseconds) => {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
};
export default delay;

View File

@ -9,6 +9,8 @@ import DatabaseContext from '../../contexts/DatabaseContext';
import ModalContext from '../../contexts/ModalContext';
import styles from './ResumePreview.module.css';
const menuToggleDataTestIdPrefix = 'resume-preview-menu-toggle-';
const ResumePreview = ({ resume }) => {
const { t, i18n } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
@ -54,6 +56,7 @@ const ResumePreview = ({ resume }) => {
onClick={handleOpen}
/>
<MdMoreHoriz
data-testid={`${menuToggleDataTestIdPrefix}${resume.id}`}
color="#fff"
size="48"
className="cursor-pointer"
@ -96,3 +99,5 @@ const ResumePreview = ({ resume }) => {
};
export default ResumePreview;
export { menuToggleDataTestIdPrefix };

View File

@ -3,8 +3,10 @@ import React, { memo } from 'react';
import { getRandomTip } from '../../data/tips';
import Logo from '../shared/Logo';
const dataTestId = 'loading-screen';
const LoadingScreen = () => (
<Modal open hideBackdrop>
<Modal data-testid={dataTestId} open hideBackdrop>
<Fade in>
<div className="w-screen h-screen flex justify-center items-center outline-none">
<div className="flex flex-col items-center">
@ -17,3 +19,4 @@ const LoadingScreen = () => (
);
export default memo(LoadingScreen);
export { dataTestId };

View File

@ -2,6 +2,8 @@ import i18next from 'i18next';
import React, { createContext, memo, useEffect, useState } from 'react';
import themeConfig from '../data/themeConfig';
const languageStorageItemKey = 'language';
const defaultState = {
theme: 'Dark',
setTheme: () => {},
@ -18,7 +20,7 @@ const SettingsProvider = ({ children }) => {
useEffect(() => {
const prefTheme = localStorage.getItem('theme') || defaultState.theme;
const prefLanguage =
localStorage.getItem('language') || defaultState.language;
localStorage.getItem(languageStorageItemKey) || defaultState.language;
setTheme(prefTheme);
setLanguage(prefLanguage);
}, []);
@ -32,7 +34,7 @@ const SettingsProvider = ({ children }) => {
}, [theme]);
useEffect(() => {
localStorage.setItem('language', language);
localStorage.setItem(languageStorageItemKey, language);
i18next.changeLanguage(language);
}, [language]);
@ -54,4 +56,4 @@ export default SettingsContext;
const memoizedProvider = memo(SettingsProvider);
export { memoizedProvider as SettingsProvider };
export { memoizedProvider as SettingsProvider, languageStorageItemKey };

View File

@ -1,15 +1,21 @@
import { navigate as mockNavigateFunction } from 'gatsby';
import React from 'react';
import {
act,
fireEvent,
getByText,
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import FirebaseStub, { DatabaseConstants } from 'gatsby-plugin-firebase';
import { SettingsProvider } from '../../../contexts/SettingsContext';
import { dataTestId as loadingScreenTestId } from '../../../components/router/LoadingScreen';
import {
SettingsProvider,
languageStorageItemKey,
} from '../../../contexts/SettingsContext';
import { ModalProvider } from '../../../contexts/ModalContext';
import { UserProvider } from '../../../contexts/UserContext';
import {
@ -18,29 +24,58 @@ import {
} from '../../../contexts/DatabaseContext';
import { ResumeProvider } from '../../../contexts/ResumeContext';
import { StorageProvider } from '../../../contexts/StorageContext';
import Wrapper from '../../../components/shared/Wrapper';
import Builder from '../builder';
describe('Builder', () => {
let resumeId = null;
let resume = null;
let mockUpdateFunction = null;
let mockDatabaseUpdateFunction = null;
beforeEach(async () => {
const fnWaitForDatabaseUpdateToHaveCompleted = async () => {
await waitFor(() => mockDatabaseUpdateFunction.mock.calls[0][0], {
timeout: DebounceWaitTime,
});
await waitFor(() => mockDatabaseUpdateFunction.mock.results[0].value);
};
const expectDatabaseUpdateToHaveCompleted = async () => {
await waitFor(
() => expect(mockDatabaseUpdateFunction).toHaveBeenCalledTimes(1),
{
timeout: DebounceWaitTime,
},
);
await waitFor(() =>
expect(
mockDatabaseUpdateFunction.mock.results[0].value,
).resolves.toBeUndefined(),
);
};
async function setup(
resumeIdParameter,
waitForLoadingScreenToDisappear = true,
waitForDatabaseUpdateToHaveCompleted = true,
) {
FirebaseStub.database().initializeData();
resumeId = DatabaseConstants.demoStateResume1Id;
resumeId = resumeIdParameter;
resume = (
await FirebaseStub.database()
.ref(`${DatabaseConstants.resumesPath}/${resumeId}`)
.once('value')
).val();
mockUpdateFunction = jest.spyOn(
mockDatabaseUpdateFunction = jest.spyOn(
FirebaseStub.database().ref(
`${DatabaseConstants.resumesPath}/${resumeId}`,
),
'update',
);
FirebaseStub.auth().signInAnonymously();
render(
<SettingsProvider>
<ModalProvider>
@ -48,7 +83,9 @@ describe('Builder', () => {
<DatabaseProvider>
<ResumeProvider>
<StorageProvider>
<Builder id={resume.id} />
<Wrapper>
<Builder id={resumeId} />
</Wrapper>
</StorageProvider>
</ResumeProvider>
</DatabaseProvider>
@ -57,23 +94,59 @@ describe('Builder', () => {
</SettingsProvider>,
);
await act(async () => {
await FirebaseStub.auth().signInAnonymously();
});
if (waitForLoadingScreenToDisappear) {
await waitForElementToBeRemoved(() =>
screen.getByTestId(loadingScreenTestId),
);
}
await waitFor(() => mockUpdateFunction.mock.calls[0][0], {
timeout: DebounceWaitTime,
if (waitForDatabaseUpdateToHaveCompleted) {
await fnWaitForDatabaseUpdateToHaveCompleted();
mockDatabaseUpdateFunction.mockClear();
}
}
describe('handles errors', () => {
describe('if resume does not exist', () => {
beforeEach(async () => {
await setup('xxxxxx', false, false);
});
it('navigates to Dashboard and displays notification', async () => {
await waitFor(() =>
expect(mockNavigateFunction).toHaveBeenCalledTimes(1),
);
expect(mockNavigateFunction).toHaveBeenCalledWith('/app/dashboard');
const notification = await screen.findByRole('alert');
expect(
getByText(
notification,
/The resume you were looking for does not exist anymore/i,
),
).toBeInTheDocument();
fireEvent.click(notification);
await waitFor(() =>
expect(
mockNavigateFunction.mock.results[0].value,
).resolves.toBeUndefined(),
);
});
});
mockUpdateFunction.mockClear();
});
describe('renders', () => {
it('first and last name', async () => {
beforeEach(async () => {
await setup(DatabaseConstants.demoStateResume1Id);
});
it('first and last name', () => {
expect(
screen.getByLabelText(new RegExp('first name', 'i')),
screen.getByRole('textbox', { name: /first name/i }),
).toHaveDisplayValue(resume.profile.firstName);
expect(
screen.getByLabelText(new RegExp('last name', 'i')),
screen.getByRole('textbox', { name: /last name/i }),
).toHaveDisplayValue(resume.profile.lastName);
expect(
screen.getAllByText(new RegExp(resume.profile.firstName, 'i')).length,
@ -84,9 +157,53 @@ describe('Builder', () => {
});
});
describe('settings', () => {
beforeEach(async () => {
await setup(DatabaseConstants.demoStateResume1Id);
});
it('allow to change the language', async () => {
const languageElement = screen.getByLabelText(/language/i);
const italianLanguageCode = 'it';
const now = new Date().getTime();
fireEvent.change(languageElement, {
target: { value: italianLanguageCode },
});
expect(languageElement).toHaveValue(italianLanguageCode);
expect(screen.queryByLabelText(/date of birth/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/data di nascita/i)).toBeInTheDocument();
const languageStorageItem = localStorage.getItem(languageStorageItemKey);
expect(languageStorageItem).toBe(italianLanguageCode);
await expectDatabaseUpdateToHaveCompleted();
const mockDatabaseUpdateFunctionCallArgument =
mockDatabaseUpdateFunction.mock.calls[0][0];
expect(mockDatabaseUpdateFunctionCallArgument.id).toBe(resumeId);
expect(mockDatabaseUpdateFunctionCallArgument.metadata.language).toBe(
italianLanguageCode,
);
expect(
mockDatabaseUpdateFunctionCallArgument.updatedAt,
).toBeGreaterThanOrEqual(now);
});
afterEach(() => {
const englishLanguageCode = 'en';
localStorage.setItem(languageStorageItemKey, englishLanguageCode);
});
});
describe('updates data', () => {
beforeEach(async () => {
await setup(DatabaseConstants.demoStateResume1Id);
});
it('when input value is changed', async () => {
const input = screen.getByLabelText(new RegExp('address line 1', 'i'));
const input = screen.getByRole('textbox', { name: /address line 1/i });
const newInputValue = 'test street 123';
const now = new Date().getTime();
@ -94,54 +211,49 @@ describe('Builder', () => {
expect(input.value).toBe(newInputValue);
await waitFor(() => mockUpdateFunction.mock.calls[0][0], {
timeout: DebounceWaitTime,
});
expect(mockUpdateFunction).toHaveBeenCalledTimes(1);
const mockUpdateFunctionCallArgument =
mockUpdateFunction.mock.calls[0][0];
expect(mockUpdateFunctionCallArgument.id).toBe(resume.id);
expect(mockUpdateFunctionCallArgument.profile.address.line1).toBe(
await expectDatabaseUpdateToHaveCompleted();
const mockDatabaseUpdateFunctionCallArgument =
mockDatabaseUpdateFunction.mock.calls[0][0];
expect(mockDatabaseUpdateFunctionCallArgument.id).toBe(resumeId);
expect(mockDatabaseUpdateFunctionCallArgument.profile.address.line1).toBe(
newInputValue,
);
expect(mockUpdateFunctionCallArgument.updatedAt).toBeGreaterThanOrEqual(
now,
);
expect(
mockDatabaseUpdateFunctionCallArgument.updatedAt,
).toBeGreaterThanOrEqual(now);
});
});
describe('settings', () => {
it('allow to change the language', async () => {
const languageSelectElement = screen.getByLabelText('Language');
const newLanguage = 'it';
const now = new Date().getTime();
describe('while loading', () => {
beforeEach(async () => {
await setup(DatabaseConstants.demoStateResume1Id, false, false);
});
fireEvent.change(languageSelectElement, {
target: { value: newLanguage },
});
it('renders loading screen', async () => {
expect(screen.getByTestId(loadingScreenTestId)).toBeInTheDocument();
expect(languageSelectElement).toHaveValue(newLanguage);
await waitForElementToBeRemoved(() =>
screen.getByTestId(loadingScreenTestId),
);
await fnWaitForDatabaseUpdateToHaveCompleted();
});
});
describe('with resume in initial state', () => {
beforeEach(async () => {
await setup(DatabaseConstants.initialStateResumeId, false, false);
});
it('displays load demo data notification', async () => {
const notification = await screen.findByRole('alert');
expect(
screen.queryByLabelText(new RegExp('date of birth', 'i')),
).toBeNull();
expect(
screen.getByLabelText(new RegExp('data di nascita', 'i')),
getByText(
notification,
/Not sure where to begin\? Try loading demo data/i,
),
).toBeInTheDocument();
await waitFor(() => mockUpdateFunction.mock.calls[0][0], {
timeout: DebounceWaitTime,
});
expect(mockUpdateFunction).toHaveBeenCalledTimes(1);
const mockUpdateFunctionCallArgument =
mockUpdateFunction.mock.calls[0][0];
expect(mockUpdateFunctionCallArgument.id).toBe(resume.id);
expect(mockUpdateFunctionCallArgument.metadata.language).toBe(
newLanguage,
);
expect(mockUpdateFunctionCallArgument.updatedAt).toBeGreaterThanOrEqual(
now,
);
fireEvent.click(notification);
});
});
});

View File

@ -1,26 +1,47 @@
import React from 'react';
import { act, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
getByText,
queryByText,
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import FirebaseStub, { DatabaseConstants } from 'gatsby-plugin-firebase';
import '../../../i18n/index';
import '../../../utils/dayjs';
import { dataTestId as loadingScreenTestId } from '../../../components/router/LoadingScreen';
import { menuToggleDataTestIdPrefix as resumePreviewMenuToggleDataTestIdPrefix } from '../../../components/dashboard/ResumePreview';
import { SettingsProvider } from '../../../contexts/SettingsContext';
import { ModalProvider } from '../../../contexts/ModalContext';
import { UserProvider } from '../../../contexts/UserContext';
import { DatabaseProvider } from '../../../contexts/DatabaseContext';
import { ResumeProvider } from '../../../contexts/ResumeContext';
import { StorageProvider } from '../../../contexts/StorageContext';
import Wrapper from '../../../components/shared/Wrapper';
import Dashboard from '../dashboard';
describe('Dashboard', () => {
let resumes = null;
let userResumes = null;
const user = DatabaseConstants.user1;
beforeEach(async () => {
const waitForResumeToBeRenderedInPreview = async (resume) => {
await screen.findByText(resume.name);
};
const expectResumeToBeRenderedInPreview = async (resume) => {
await waitFor(() => {
expect(screen.getByText(resume.name)).toBeInTheDocument();
});
};
async function setup(waitForLoadingScreenToDisappear = true) {
FirebaseStub.database().initializeData();
resumes = (
userResumes = (
await FirebaseStub.database()
.ref(DatabaseConstants.resumesPath)
.orderByChild('user')
@ -28,6 +49,8 @@ describe('Dashboard', () => {
.once('value')
).val();
FirebaseStub.auth().signInAnonymously();
render(
<SettingsProvider>
<ModalProvider>
@ -35,7 +58,9 @@ describe('Dashboard', () => {
<DatabaseProvider>
<ResumeProvider>
<StorageProvider>
<Dashboard user={user} />
<Wrapper>
<Dashboard user={user} />
</Wrapper>
</StorageProvider>
</ResumeProvider>
</DatabaseProvider>
@ -44,33 +69,132 @@ describe('Dashboard', () => {
</SettingsProvider>,
);
await act(async () => {
await FirebaseStub.auth().signInAnonymously();
});
await waitFor(() => screen.getByText('Create Resume'));
});
if (waitForLoadingScreenToDisappear) {
await waitForElementToBeRemoved(() =>
screen.getByTestId(loadingScreenTestId),
);
}
}
describe('renders', () => {
beforeEach(async () => {
await setup();
});
it('document title', async () => {
expect(document.title).toEqual('Dashboard | Reactive Resume');
await waitFor(() => {
expect(document.title).toEqual('Dashboard | Reactive Resume');
});
});
it('create resume', async () => {
expect(screen.getByText('Create Resume')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/create resume/i)).toBeInTheDocument();
});
});
it('preview of user resumes', async () => {
expect(Object.keys(resumes)).toHaveLength(2);
expect(Object.keys(userResumes)).toHaveLength(2);
expect(Object.values(resumes)[0].user).toEqual(user.uid);
await expectResumeToBeRenderedInPreview(Object.values(userResumes)[0]);
await expectResumeToBeRenderedInPreview(Object.values(userResumes)[1]);
});
});
describe('when resume is deleted', () => {
let mockDatabaseRemoveFunction = null;
let resumeToDelete = null;
let undeletedResume = null;
let resumeToDeleteId = null;
const waitForDatabaseRemoveToHaveCompleted = async () => {
await waitFor(() => mockDatabaseRemoveFunction.mock.results[0].value);
};
const expectDatabaseRemoveToHaveCompleted = async () => {
await waitFor(() =>
expect(mockDatabaseRemoveFunction).toHaveBeenCalledTimes(1),
);
await waitFor(() =>
expect(
mockDatabaseRemoveFunction.mock.results[0].value,
).resolves.toBeUndefined(),
);
};
beforeEach(async () => {
await setup();
[resumeToDelete, undeletedResume] = Object.values(userResumes);
resumeToDeleteId = resumeToDelete.id;
mockDatabaseRemoveFunction = jest.spyOn(
FirebaseStub.database().ref(
`${DatabaseConstants.resumesPath}/${resumeToDeleteId}`,
),
'remove',
);
const resumeToDeleteMenuToggle = await screen.findByTestId(
`${resumePreviewMenuToggleDataTestIdPrefix}${resumeToDeleteId}`,
);
fireEvent.click(resumeToDeleteMenuToggle);
const menuItems = screen.getAllByRole('menuitem');
let deleteMenuItem = null;
for (let index = 0; index < menuItems.length; index++) {
if (queryByText(menuItems[index], /delete/i)) {
deleteMenuItem = menuItems[index];
break;
}
}
fireEvent.click(deleteMenuItem);
});
it('removes it from database and preview', async () => {
await expectDatabaseRemoveToHaveCompleted();
await waitFor(() =>
expect(screen.queryByText(resumeToDelete.name)).toBeNull(),
);
await expectResumeToBeRenderedInPreview(undeletedResume);
});
it('displays notification', async () => {
const notification = await screen.findByRole('alert');
expect(
screen.getByText(Object.values(resumes)[0].name),
).toBeInTheDocument();
expect(Object.values(resumes)[1].user).toEqual(user.uid);
expect(
screen.getByText(Object.values(resumes)[1].name),
getByText(
notification,
new RegExp(`${resumeToDelete.name} was deleted successfully`, 'i'),
),
).toBeInTheDocument();
fireEvent.click(notification);
await waitForDatabaseRemoveToHaveCompleted();
});
it('closes menu', async () => {
const menuItems = screen.queryAllByRole('menuitem');
expect(menuItems).toHaveLength(0);
await waitForDatabaseRemoveToHaveCompleted();
});
});
describe('while loading', () => {
beforeEach(async () => {
await setup(false);
});
it('renders loading screen', async () => {
expect(screen.getByTestId(loadingScreenTestId)).toBeInTheDocument();
await waitForElementToBeRemoved(() =>
screen.getByTestId(loadingScreenTestId),
);
await waitForResumeToBeRenderedInPreview(Object.values(userResumes)[0]);
await waitForResumeToBeRenderedInPreview(Object.values(userResumes)[1]);
});
});
});