refactor: migrate to cypress component test

This commit is contained in:
mutoe 2021-08-16 20:06:17 +08:00
parent 7c14a84dea
commit fbc1985de2
No known key found for this signature in database
GPG Key ID: ABE5E78D073FC208
40 changed files with 387 additions and 2597 deletions

View File

@ -16,6 +16,7 @@
"rules": {
"no-undef": "off",
"no-unused-vars": "off",
"no-void": "off",
"comma-dangle": ["warn", "always-multiline"],
"func-call-spacing": "off",
"prefer-const": "off",

View File

@ -1,4 +1,6 @@
{
"projectId": "j7s91r",
"baseUrl": "http://localhost:3000"
"baseUrl": "http://localhost:3000",
"componentFolder": "src",
"testFiles": "**/*.spec.ts?(x)"
}

View File

@ -12,10 +12,21 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
const path = require('path')
const { startDevServer } = require('@cypress/vite-dev-server')
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('dev-server:start', (options) => {
return startDevServer({
options,
viteConfig: {
configFile: path.resolve(__dirname, '..', '..', 'vite.config.js'),
},
})
})
return config
}

View File

@ -13,8 +13,5 @@
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import '@testing-library/cypress/add-commands'

View File

@ -9,10 +9,10 @@
"lint:script": "eslint \"{src/**/*.{ts,vue},cypress/**/*.js}\"",
"lint:tsc": "vue-tsc --noEmit",
"lint": "concurrently 'yarn build' 'yarn lint:tsc' 'yarn lint:script'",
"test:unit": "jest",
"test:unit": "cypress run-ct",
"test:e2e": "yarn build && concurrently -k \"yarn serve\" \"cypress run -c baseUrl=http://localhost:5000\"",
"test:e2e:ci": "cypress run -C cypress.prod.json",
"test": "yarn test:e2e"
"test": "test:unit && yarn test:e2e"
},
"dependencies": {
"@harlem/core": "^1.3.2",
@ -23,14 +23,13 @@
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/vue": "^6.4.2",
"@types/jest": "^27.0.1",
"@cypress/vite-dev-server": "^2.0.7",
"@cypress/vue": "^3.0.3",
"@testing-library/cypress": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^4.29.1",
"@typescript-eslint/parser": "^4.29.1",
"@vitejs/plugin-vue": "^1.4.0",
"@vue/compiler-sfc": "^3.2.2",
"babel-jest": "^27.0.6",
"concurrently": "^6.2.1",
"cypress": "^8.2.0",
"eslint": "^7.32.0",
@ -41,15 +40,11 @@
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-vue": "^7.16.0",
"husky": "^4.3.8",
"jest": "^27.0.6",
"jsdom": "^17.0.0",
"lint-staged": "^11.1.2",
"rollup-plugin-analyzer": "^4.0.0",
"ts-jest": "^27.0.4",
"typescript": "^4.3.5",
"vite": "^2.4.4",
"vue-tsc": "^0.2.2",
"vue3-jest": "^27.0.0-alpha.2"
"vue-tsc": "^0.2.2"
},
"husky": {
"hooks": {
@ -60,36 +55,5 @@
"lint-staged": {
"src/**/*.{ts,vue}": "eslint --fix",
"cypress/**/*.js": "eslint --fix"
},
"jest": {
"preset": "ts-jest",
"globals": {
"ts-jest": {}
},
"testEnvironment": "jsdom",
"transform": {
"^.+\\.vue$": "vue3-jest",
"^.+\\js$": "babel-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.{ts,vue}",
"!<rootDir>/src/config.ts"
],
"moduleFileExtensions": [
"vue",
"ts",
"js",
"json",
"node"
],
"testMatch": [
"<rootDir>/src/**/*.spec.ts"
],
"modulePaths": [
"<rootDir>"
],
"setupFilesAfterEnv": [
"<rootDir>/src/setup-test.ts"
]
}
}

View File

@ -1,18 +1,10 @@
import { render } from '@testing-library/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { mount } from '@cypress/vue'
import AppFooter from './AppFooter.vue'
describe('# AppFooter', () => {
beforeEach(async () => {
await router.push('/')
})
it('should render correctly', () => {
const { container } = render(AppFooter, {
global: { plugins: [registerGlobalComponents, router] },
})
mount(AppFooter)
expect(container).toBeInTheDocument()
cy.contains('Real world app')
})
})

View File

@ -1,4 +1,4 @@
import { fireEvent, render, waitFor } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { router } from 'src/router'
import AppLink from './AppLink.vue'
@ -8,20 +8,16 @@ describe('# AppLink', () => {
})
it('should redirect to another page when click the link', async () => {
// given
const { container, getByRole } = render(AppLink, {
mount(AppLink, {
global: { plugins: [router] },
props: { name: 'tag', params: { tag: 'foo' } },
slots: { default: 'Go to Foo tag' },
})
expect(container).toHaveTextContent('Go to Foo tag')
cy.contains('Go to Foo tag')
// when
const linkElement = getByRole('link', { name: 'tag' })
await fireEvent.click(linkElement)
// then
await waitFor(() => expect(linkElement).toHaveClass('router-link-active'))
cy.findByRole('link', { name: 'tag' })
.click()
.should('have.class', 'router-link-active')
})
})

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { updateUser, user } from 'src/store/user'
@ -10,30 +10,39 @@ describe('# AppNavigation', () => {
await router.push('/')
})
it('should render Sign in and Sign up when user not logged', () => {
const { container } = render(AppNavigation, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelectorAll('.nav-item')).toHaveLength(3)
expect(container.textContent).toContain('Home')
expect(container.textContent).toContain('Sign in')
expect(container.textContent).toContain('Sign up')
afterEach(() => {
Cypress.vueWrapper.unmount()
})
it('should render xxx when user logged', () => {
updateUser({ id: 1, username: 'foo', email: '', token: '', bio: undefined, image: undefined })
const { container } = render(AppNavigation, {
it('should render Sign in and Sign up when user not logged', () => {
mount(AppNavigation, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
})
expect(container.querySelectorAll('.nav-item')).toHaveLength(4)
expect(container.textContent).toContain('Home')
expect(container.textContent).toContain('New Post')
expect(container.textContent).toContain('Settings')
expect(container.textContent).toContain('foo')
cy.get('.nav-item')
.should('have.length', 3)
.should('contain.text', 'Home')
.should('contain.text', 'Sign in')
.should('contain.text', 'Sign up')
})
it('should render xxx when user logged', () => {
updateUser({ id: 1, username: 'foo', email: '', token: '', bio: undefined, image: undefined })
mount(AppNavigation, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
})
cy.get('.nav-item')
.should('have.length', 4)
.should('contain.text', 'Home')
.should('contain.text', 'New Post')
.should('contain.text', 'Settings')
.should('contain.text', 'foo')
})
})

View File

@ -1,26 +1,26 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import AppPagination from './AppPagination.vue'
describe('# AppPagination', () => {
it('should highlight current active page', () => {
const { container } = render(AppPagination, {
mount(AppPagination, {
props: { page: 1, count: 15 },
})
const pageItems = container.querySelectorAll('.page-item')
expect(pageItems).toHaveLength(2)
expect(pageItems[0]).toHaveClass('active')
cy.get('.page-item')
.should('have.length', 2)
.its(0)
.should('have.class', 'active')
})
it('should call onPageChange when click a page item', async () => {
const { getByRole, emitted } = render(AppPagination, {
mount(AppPagination, {
props: { page: 1, count: 15 },
})
await fireEvent.click(getByRole('link', { name: 'Go to page 2' }))
cy.findByRole('link', { name: 'Go to page 2' }).click()
const events = emitted()
expect(events['page-change']).toHaveLength(1)
expect(events['page-change']?.[0]).toEqual([2])
const events = Cypress.vueWrapper.emitted()
cy.wrap(events).should('have.property', 'page-change', [2])
})
})

View File

@ -9,6 +9,7 @@
role="link"
:aria-label="`Go to page ${pageNumber}`"
class="page-link"
href="javascript:"
@click="onPageChange(pageNumber)"
>{{ pageNumber }}</a>
</li>

View File

@ -1,16 +1,10 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getArticle } from 'src/services/article/getArticle'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetail from './ArticleDetail.vue'
jest.mock('src/services/article/getArticle')
describe.skip('# ArticleDetail', () => {
const mockGetArticle = getArticle as jest.MockedFunction<typeof getArticle>
beforeEach(async () => {
await router.push({
name: 'article',
@ -19,29 +13,27 @@ describe.skip('# ArticleDetail', () => {
})
it('should render markdown body correctly', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdown })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
cy.intercept('/articles/:slug', { ...fixtures.article, body: fixtures.markdown })
mount(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelector('.article-content')).toMatchSnapshot()
cy.get('.article-content').should('have.html', '')
})
it('should render markdown (zh-CN) body correctly', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdownCN })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
mount(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelector('.article-content')).toMatchSnapshot()
cy.get('.article-content').should('have.html', '')
})
it('should filter the xss content in Markdown body', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdownXss })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
mount(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelector('.article-content')?.textContent).not.toContain('alert')
cy.get('.article-content').should('have.html', '')
})
})

View File

@ -1,4 +1,4 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import fixtures from 'src/utils/test/fixtures'
@ -10,37 +10,34 @@ describe('# ArticleDetailComment', () => {
})
it('should render correctly', () => {
const { container, queryByRole } = render(ArticleDetailComment, {
mount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: { comment: fixtures.comment },
})
expect(container.querySelector('.card-text')).toHaveTextContent('Comment body')
expect(container.querySelector('.date-posted')).toHaveTextContent('1/1/2020')
expect(queryByRole('button', { name: 'Delete comment' })).toBeNull()
cy.get('.card-text').should('have.text', 'Comment body')
cy.get('.date-posted').should('have.text', '01/01/2020')
cy.findByRole('button', { name: 'Delete comment' }).should('not.exist')
})
it('should delete comment button when comment author is same user', () => {
const { getByRole } = render(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: {
comment: fixtures.comment,
username: fixtures.author.username,
},
})
expect(getByRole('button', { name: 'Delete comment' })).toBeInTheDocument()
})
it('should emit remove comment when click remove comment button', async () => {
const { getByRole, emitted } = render(ArticleDetailComment, {
mount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: { comment: fixtures.comment, username: fixtures.author.username },
})
await fireEvent.click(getByRole('button', { name: 'Delete comment' }))
cy.findByRole('button', { name: 'Delete comment' }).should('exist')
})
const events = emitted()
expect(events['remove-comment']).toHaveLength(1)
it('should emit remove comment when click remove comment button', async () => {
mount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: { comment: fixtures.comment, username: fixtures.author.username },
})
cy.findByRole('button', { name: 'Delete comment' }).click()
const events = Cypress.vueWrapper.emitted()
cy.wrap(events).should('have.property', 'remove-comment')
})
})

View File

@ -54,7 +54,5 @@ const emit = defineEmits<{
(e: 'remove-comment'): boolean
}>()
const showRemove = $computed(() => (
props.username !== undefined && props.username === props.comment.author.username
))
const showRemove = $computed(() => (props.username !== undefined && props.username === props.comment.author.username))
</script>

View File

@ -1,21 +1,11 @@
import { render, waitFor } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getCommentsByArticle } from 'src/services/comment/getComments'
import { deleteComment } from 'src/services/comment/postComment'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetailComments from './ArticleDetailComments.vue'
jest.mock('src/services/comment/getComments')
jest.mock('src/services/comment/postComment')
describe('# ArticleDetailComments', () => {
const mockGetCommentsByArticle = getCommentsByArticle as jest.MockedFunction<typeof getCommentsByArticle>
const mockDeleteComment = deleteComment as jest.MockedFunction<typeof deleteComment>
beforeEach(async () => {
mockGetCommentsByArticle.mockResolvedValue([fixtures.comment])
await router.push({
name: 'article',
params: { slug: fixtures.article.slug },
@ -23,42 +13,48 @@ describe('# ArticleDetailComments', () => {
})
it('should render correctly', async () => {
const { container } = render(asyncComponentWrapper(ArticleDetailComments), {
global: { plugins: [registerGlobalComponents, router] },
cy.intercept('GET', '/api/articles/*/comments', { body: { comments: [fixtures.comment] } }).as('retrieveComments')
mount(ArticleDetailComments, {
global: {
plugins: [registerGlobalComponents, router],
},
})
expect(mockGetCommentsByArticle).toBeCalledWith('article-foo')
expect(container).toBeInTheDocument()
cy.wait('@retrieveComments').its('request.url').should('contain', 'article-foo')
})
// TODO: resolve the Cypress.vue is undefined
it.skip('should display new comment when post new comment', async () => {
cy.intercept('GET', '/api/articles/*/comments', { body: { comments: [fixtures.comment] } }).as('retrieveComments')
// given
const { container } = render(asyncComponentWrapper(ArticleDetailComments), {
mount(ArticleDetailComments, {
global: { plugins: [registerGlobalComponents, router] },
})
await waitFor(() => expect(mockGetCommentsByArticle).toBeCalled())
expect(container.querySelectorAll('.card')).toHaveLength(1)
cy.wait('@retrieveComments')
cy.get('.card').should('have.length', 1)
// when
// wrapper.findComponent(ArticleDetailCommentsForm).vm.$emit('add-comment', fixtures.comment2)
// await nextTick()
Cypress.vue.$emit('add-comment', fixtures.comment2)
// then
expect(container.querySelectorAll('.card')).toHaveLength(2)
cy.get('.card').should('have.length', 2)
})
it.skip('should call remove comment service when click delete button', async () => {
cy.intercept('DELETE', '/api/articles/*/comments', { statusCode: 200 }).as('deleteComment')
// given
render(asyncComponentWrapper(ArticleDetailComments), {
mount(ArticleDetailComments, {
global: { plugins: [registerGlobalComponents, router] },
})
await waitFor(() => expect(mockGetCommentsByArticle).toBeCalled())
// when
// wrapper.findComponent(ArticleDetailComment).vm.$emit('remove-comment')
Cypress.vue.$emit('remove-comment')
// then
expect(mockDeleteComment).toBeCalledWith('article-foo', 1)
cy.wait('@deleteComment')
})
})

View File

@ -17,6 +17,7 @@
import { getCommentsByArticle } from 'src/services/comment/getComments'
import { deleteComment } from 'src/services/comment/postComment'
import { user } from 'src/store/user'
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import ArticleDetailComment from './ArticleDetailComment.vue'
import ArticleDetailCommentsForm from './ArticleDetailCommentsForm.vue'
@ -37,5 +38,7 @@ const removeComment = async (commentId: number) => {
comments = comments.filter(c => c.id !== commentId)
}
comments = await getCommentsByArticle(slug)
onMounted(async () => {
comments = await getCommentsByArticle(slug)
})
</script>

View File

@ -1,53 +1,43 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import ArticleDetailCommentsForm from 'src/components/ArticleDetailCommentsForm.vue'
import { useProfile } from 'src/composable/useProfile'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { postComment } from 'src/services/comment/postComment'
import fixtures from 'src/utils/test/fixtures'
jest.mock('src/composable/useProfile')
jest.mock('src/services/comment/postComment')
describe('# ArticleDetailCommentsForm', () => {
const mockUseProfile = useProfile as jest.MockedFunction<typeof useProfile>
const mockPostComment = postComment as jest.MockedFunction<typeof postComment>
beforeEach(async () => {
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
mockPostComment.mockResolvedValue(fixtures.comment2)
mockUseProfile.mockReturnValue({
profile: fixtures.author,
updateProfile: jest.fn(),
})
})
it('should display sign in button when user not logged', () => {
mockUseProfile.mockReturnValue({ profile: null, updateProfile: jest.fn() })
const { container } = render(ArticleDetailCommentsForm, {
cy.intercept('POST', '/api/articles/*/comments', fixtures.comment2).as('createComment')
mount(ArticleDetailCommentsForm, {
global: { plugins: [registerGlobalComponents, router] },
props: { articleSlug: fixtures.article.slug },
})
expect(container.textContent).toContain('add comments on this article')
cy.contains('add comments on this article')
})
it('should display form when user logged', async () => {
cy.intercept('POST', '/api/articles/*/comments', { statusCode: 200 }).as('createComment')
// given
const { getByRole, emitted } = render(ArticleDetailCommentsForm, {
mount(ArticleDetailCommentsForm, {
global: { plugins: [registerGlobalComponents, router] },
props: { articleSlug: fixtures.article.slug },
})
// when
const inputElement = getByRole('textbox', { name: 'Write comment' })
await fireEvent.update(inputElement, 'some texts...')
await fireEvent.click(getByRole('button', { name: 'Submit' }))
cy.findByRole('textbox', { name: 'Write comment' })
.type('some texts...')
cy.findByRole('button', { name: 'Submit' })
// then
expect(mockPostComment).toBeCalledWith('article-foo', 'some texts...')
cy.get('@createComment').should('be.calledWith', 'article-foo', 'some texts...')
const { submit } = emitted()
expect(submit).toHaveLength(1)
const events = Cypress.vueWrapper.emitted()
cy.wrap(events).should('have.property', 'submit')
})
})

View File

@ -51,7 +51,7 @@ const emit = defineEmits<{
}>()
const username = $computed(() => checkAuthorization(user) ? user.value.username : '')
const { profile } = useProfile({ username })
const { profile } = useProfile({ username: $raw(username) })
let comment = $ref('')

View File

@ -1,23 +1,10 @@
import { fireEvent, render } from '@testing-library/vue'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { deleteArticle } from 'src/services/article/deleteArticle'
import { deleteFavoriteArticle, postFavoriteArticle } from 'src/services/article/favoriteArticle'
import { deleteFollowProfile, postFollowProfile } from 'src/services/profile/followProfile'
import { updateUser, user } from 'src/store/user'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetailMeta from './ArticleDetailMeta.vue'
jest.mock('src/services/article/deleteArticle')
jest.mock('src/services/profile/followProfile')
jest.mock('src/services/article/favoriteArticle')
const globalMountOptions: GlobalMountOptions = {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
}
describe('# ArticleDetailMeta', () => {
const editButton = 'Edit article'
const deleteButton = 'Delete article'
@ -26,109 +13,133 @@ describe('# ArticleDetailMeta', () => {
const favoriteButton = 'Favorite article'
const unfavoriteButton = 'Unfavorite article'
const mockDeleteArticle = deleteArticle as jest.MockedFunction<typeof deleteArticle>
const mockFollowUser = postFollowProfile as jest.MockedFunction<typeof postFollowProfile>
const mockUnfollowUser = deleteFollowProfile as jest.MockedFunction<typeof deleteFollowProfile>
const mockFavoriteArticle = postFavoriteArticle as jest.MockedFunction<typeof postFavoriteArticle>
const mockUnfavoriteArticle = deleteFavoriteArticle as jest.MockedFunction<typeof deleteFavoriteArticle>
beforeEach(async () => {
mockFollowUser.mockResolvedValue({ isOk: () => true } as any)
mockUnfollowUser.mockResolvedValue({ isOk: () => true } as any)
mockFavoriteArticle.mockResolvedValue({ isOk: () => true, value: fixtures.article } as any)
mockUnfavoriteArticle.mockResolvedValue({ isOk: () => true, value: fixtures.article } as any)
updateUser(fixtures.user)
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
updateUser(fixtures.user)
})
it('should display edit button when user is author', () => {
const { queryByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
expect(queryByRole('link', { name: editButton })).toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).not.toBeInTheDocument()
cy.findByRole('link', { name: editButton }).should('exist')
cy.findByRole('button', { name: followButton }).should('not.exist')
})
it('should display follow button when user not author', () => {
updateUser({ ...fixtures.user, username: 'user2' })
const { queryByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
expect(queryByRole('link', { name: editButton })).not.toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).toBeInTheDocument()
cy.findByRole('link', { name: editButton }).should('not.exist')
cy.findByRole('button', { name: followButton }).should('exist')
})
it('should not display follow button and edit button when user not logged', () => {
updateUser(null)
const { queryByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
expect(queryByRole('button', { name: editButton })).not.toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).not.toBeInTheDocument()
cy.findByRole('button', { name: editButton }).should('not.exist')
cy.findByRole('button', { name: followButton }).should('not.exist')
})
it('should call delete article service when click delete button', async () => {
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
cy.intercept('DELETE', '/api/articles/*', { statusCode: 200 }).as('deleteArticle')
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
await fireEvent.click(getByRole('button', { name: deleteButton }))
cy.findByRole('button', { name: deleteButton }).click()
expect(mockDeleteArticle).toBeCalledWith('article-foo')
cy.wait('@deleteArticle').its('request.url').should('contain', 'article-foo')
})
it('should call follow service when click follow button', async () => {
cy.intercept('POST', '/api/profiles/*/follow', { statusCode: 200 }).as('followProfile')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
await fireEvent.click(getByRole('button', { name: followButton }))
cy.findByRole('button', { name: followButton }).click()
expect(mockFollowUser).toBeCalledWith('Author name')
cy.wait('@followProfile').its('request.url').should('contain', 'Author%20name')
})
it('should call unfollow service when click follow button and not followed author', async () => {
cy.intercept('DELETE', '/api/profiles/*/follow', { statusCode: 200 }).as('unfollowProfile')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: { ...fixtures.article, author: { ...fixtures.article.author, following: true } } },
})
await fireEvent.click(getByRole('button', { name: unfollowButton }))
cy.findByRole('button', { name: unfollowButton }).click()
expect(mockUnfollowUser).toBeCalledWith('Author name')
cy.wait('@unfollowProfile').its('request.url').should('contain', 'Author')
})
it('should call favorite article service when click favorite button', async () => {
cy.intercept('POST', '/api/articles/*/favorite', { statusCode: 200 }).as('favoriteArticle')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: { ...fixtures.article, favorited: false } },
})
await fireEvent.click(getByRole('button', { name: favoriteButton }))
cy.findByRole('button', { name: favoriteButton }).click()
expect(mockFavoriteArticle).toBeCalledWith('article-foo')
cy.wait('@favoriteArticle').its('request.url').should('contain', 'article-foo')
})
it('should call favorite article service when click unfavorite button', async () => {
cy.intercept('DELETE', '/api/articles/*/favorite', { statusCode: 200 }).as('unfavoriteArticle')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: { ...fixtures.article, favorited: true } },
})
await fireEvent.click(getByRole('button', { name: unfavoriteButton }))
cy.findByRole('button', { name: unfavoriteButton }).click()
expect(mockUnfavoriteArticle).toBeCalledWith('article-foo')
cy.wait('@unfavoriteArticle').its('request.url').should('contain', 'article-foo')
})
})

View File

@ -1,32 +1,28 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import ArticlesList from 'src/components/ArticlesList.vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getArticles } from 'src/services/article/getArticles'
import fixtures from 'src/utils/test/fixtures'
import asyncComponentWrapper from '../utils/test/async-component-wrapper'
jest.mock('src/services/article/getArticles')
describe('# ArticlesList', () => {
const globalMountOptions: GlobalMountOptions = {
plugins: [registerGlobalComponents, router],
}
const mockFetchArticles = getArticles as jest.MockedFunction<typeof getArticles>
beforeEach(async () => {
mockFetchArticles.mockResolvedValue({ articles: [fixtures.article], articlesCount: 1 })
await router.push('/')
})
it('should render correctly', async () => {
const wrapper = render(asyncComponentWrapper(ArticlesList), {
cy.intercept('GET', '/api/articles?*', { body: { articles: [fixtures.article], articlesCount: 1 } })
.as('mockRequest')
mount(ArticlesList, {
global: globalMountOptions,
})
expect(wrapper).toBeTruthy()
expect(mockFetchArticles).toBeCalledTimes(1)
cy.wait('@mockRequest')
cy.contains('Article foo')
})
})

View File

@ -35,6 +35,7 @@
<script lang="ts" setup>
import { useArticles } from 'src/composable/useArticles'
import { onMounted } from 'vue'
import AppPagination from './AppPagination.vue'
import ArticlesListArticlePreview from './ArticlesListArticlePreview.vue'
import ArticlesListNavigation from './ArticlesListNavigation.vue'
@ -51,5 +52,7 @@ const {
username,
} = useArticles()
await fetchArticles()
onMounted(async () => {
await fetchArticles()
})
</script>

View File

@ -1,32 +1,27 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import ArticlesListArticlePreview from 'src/components/ArticlesListArticlePreview.vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import fixtures from 'src/utils/test/fixtures'
const mockFavoriteArticle = jest.fn()
jest.mock('src/composable/useFavoriteArticle', () => ({
useFavoriteArticle: () => ({
favoriteProcessGoing: false,
favoriteArticle: mockFavoriteArticle,
}),
}))
describe('# ArticlesListArticlePreview', () => {
const favoriteButton = 'Favorite article'
beforeEach(async () => {
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
})
it('should call favorite method when click favorite button', async () => {
const { getByRole } = render(ArticlesListArticlePreview, {
cy.intercept('POST', '/api/articles/*/favorite', { body: { article: fixtures.articleAfterFavorite } })
.as('mockFavorite')
mount(ArticlesListArticlePreview, {
global: { plugins: [registerGlobalComponents, router] },
props: { article: fixtures.article },
})
await fireEvent.click(getByRole('button', { name: favoriteButton }))
cy.findByRole('button', { name: favoriteButton }).click()
expect(mockFavoriteArticle).toBeCalledTimes(1)
cy.wait('@mockFavorite')
// TODO: Cypress.vueWrapper is undefined
// const events = Cypress.vueWrapper.emitted()
// cy.wrap(events).should('have.property', 'update', fixtures.articleAfterFavorite)
})
})

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import ArticlesListNavigation from 'src/components/ArticlesListNavigation.vue'
import registerGlobalComponents from 'src/plugins/global-components'
@ -18,26 +18,26 @@ describe('# ArticlesListNavigation', () => {
})
it('should render global feed item when passed global feed prop', () => {
const { container } = render(ArticlesListNavigation, {
mount(ArticlesListNavigation, {
global: globalMountOptions,
props: { tag: '', username: '', useGlobalFeed: true },
})
const items = container.querySelectorAll('.nav-item')
expect(items).toHaveLength(1)
expect(items[0].textContent).toContain('Global Feed')
cy.get('.nav-item')
.should('have.length', 1)
.should('contain.text', 'Global Feed')
})
it('should render full item', () => {
const { container } = render(ArticlesListNavigation, {
mount(ArticlesListNavigation, {
global: globalMountOptions,
props: { tag: 'foo', username: '', useGlobalFeed: true, useMyFeed: true, useTagFeed: true },
})
const items = container.querySelectorAll('.nav-item')
expect(items).toHaveLength(3)
expect(items[0].textContent).toContain('Global Feed')
expect(items[1].textContent).toContain('Your Feed')
expect(items[2].textContent).toContain('foo')
cy.get('.nav-item')
.should('have.length', 3)
.should('contain.text', 'Global Feed')
.should('contain.text', 'Your Feed')
.should('contain.text', 'foo')
})
})

View File

@ -1,29 +1,17 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import PopularTags from 'src/components/PopularTags.vue'
import { useTags } from 'src/composable/useTags'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { ref } from 'vue'
jest.mock('src/composable/useTags')
describe('# PopularTags', () => {
const mockUseTags = useTags as jest.MockedFunction<typeof useTags>
it('should render correctly', async () => {
cy.intercept('GET', '/api/tags', { body: { tags: ['foo', 'bar'] } })
beforeEach(async () => {
const mockFetchTags = jest.fn()
mockUseTags.mockReturnValue({
tags: ref(['foo', 'bar']),
fetchTags: mockFetchTags,
})
await router.push('/')
})
it.skip('should render correctly', async () => {
const { container } = render(PopularTags, {
mount(PopularTags, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelectorAll('.tag-pill')).toHaveLength(2)
cy.get('.tag-pill')
.should('have.length', 2)
})
})

View File

@ -16,8 +16,11 @@
<script lang="ts" setup>
import { useTags } from 'src/composable/useTags'
import { onMounted } from 'vue'
const { tags, fetchTags } = useTags()
await fetchTags()
onMounted(async () => {
await fetchTags()
})
</script>

View File

@ -1,26 +1,27 @@
import { getProfile } from 'src/services/profile/getProfile'
import { watch } from 'vue'
import { watch, ref } from 'vue'
import type { Ref } from 'vue'
interface UseProfileProps {
username: string
username: Ref<string>
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function useProfile ({ username }: UseProfileProps) {
let profile = $ref<Profile | null>(null)
const profile = ref<Profile | null>(null)
async function fetchProfile (): Promise<void> {
updateProfile(null)
if (!username) return
const profileData = await getProfile(username)
const profileData = await getProfile(username.value)
updateProfile(profileData)
}
function updateProfile (profileData: Profile | null): void {
profile = profileData
profile.value = profileData
}
watch($raw(username), fetchProfile, { immediate: true })
watch(username, fetchProfile, { immediate: true })
return {
profile,

View File

@ -3,7 +3,7 @@ import { ref } from 'vue'
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function useTags () {
const tags = ref<string[]>([])
let tags = ref<string[]>([])
async function fetchTags (): Promise<void> {
tags.value = []

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { router } from 'src/router'
import Article from './Article.vue'
@ -8,10 +8,10 @@ describe('# Article', () => {
})
it('should render correctly', () => {
const { container } = render(Article, {
mount(Article, {
global: { plugins: [router] },
})
expect(container.textContent).toContain('Article is downloading')
cy.contains('Article is downloading')
})
})

View File

@ -71,15 +71,14 @@ import ArticlesList from 'src/components/ArticlesList.vue'
import { useFollow } from 'src/composable/useFollowProfile'
import { useProfile } from 'src/composable/useProfile'
import { checkAuthorization, user } from 'src/store/user'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const username = $computed<string>(() => route.params.username as string)
const { profile, updateProfile } = useProfile({ username })
const { profile, updateProfile } = useProfile({ username: $raw(username) })
const following = $computed<boolean>(() => profile?.following ?? false)
const following = $computed<boolean>(() => profile.value?.following ?? false)
const { followProcessGoing, toggleFollow } = useFollow({
following,
username,

View File

@ -1,17 +0,0 @@
import 'jest'
import '@testing-library/jest-dom'
jest.spyOn(window.Storage.prototype, 'getItem').mockReturnValue('')
jest.spyOn(window.Storage.prototype, 'setItem').mockImplementation()
jest.mock('src/config', () => ({
CONFIG: {
API_HOST: '',
},
}))
// eslint-disable-next-line @typescript-eslint/no-empty-function
global.fetch = jest.fn().mockImplementation(() => new Promise(() => {}))
afterEach(() => {
jest.clearAllMocks()
})

View File

@ -7,36 +7,34 @@ describe('# Create async process', function () {
it('should expect active as Vue Ref type', function () {
const { active } = createAsyncProcess(someProcess)
expect(isRef(active)).toBe(true)
cy.wrap(isRef(active)).should('be.true')
})
it('should correctly test active functionality', async function () {
const { active, run } = createAsyncProcess(someProcess)
expect(active.value).toBe(false)
cy.wrap(active.value).should('be.false')
const promise = run()
void run()
expect(active.value).toBe(true)
cy.wrap(active.value).should('be.true')
await promise
expect(active.value).toBe(false)
cy.wrap(active.value).should('be.false')
})
it('should expect run as a function', function () {
const { run } = createAsyncProcess(someProcess)
expect(run).toBeInstanceOf(Function)
cy.wrap(run).should('be.instanceOf', Function)
})
it('should expect original function called with correct params and return correct data', async function () {
const someProcess = jest.fn().mockImplementation(a => Promise.resolve({ a, b: null }))
const someProcess = cy.stub().callsFake(a => Promise.resolve({ a, b: null }))
const { run } = createAsyncProcess(someProcess)
const result = await run(null)
expect(someProcess).toBeCalledWith(null)
expect(result).toEqual({ a: null, b: null })
cy.wrap(someProcess).should('be.calledWith', null)
cy.wrap(result).should('eq', { a: null, b: null })
})
})

View File

@ -5,6 +5,6 @@ describe('# Date filters', function () {
const dateString = '2019-01-01 00:00:00'
const result = dateFilter(dateString)
expect(result).toMatchInlineSnapshot('"January 1"')
cy.wrap(result).should('equal', 'January 1')
})
})

View File

@ -13,9 +13,9 @@ describe('# mapAuthorizationResponse', function () {
const result = mapAuthorizationResponse<Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isOk()).toBe(true)
expect(result.value).toEqual(RESPONSE)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isOk()).should('be.true')
cy.wrap(result.value).should('equal', RESPONSE)
})
it('should return Either with AuthorizationError and failed Response', function () {
@ -24,9 +24,9 @@ describe('# mapAuthorizationResponse', function () {
const result = mapAuthorizationResponse<Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isFail()).toBe(true)
expect(result.value).toBeInstanceOf(AuthorizationError)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isFail()).should('be.true')
cy.wrap(result.value).should('be.instanceOf', AuthorizationError)
})
it('should throw NetworkError when Response is failed with status != 401', function () {
@ -35,7 +35,7 @@ describe('# mapAuthorizationResponse', function () {
expect(() => {
mapAuthorizationResponse<Partial<Response>>(response)
}).toThrowError(NetworkError)
}).to.throw()
})
})
@ -48,9 +48,9 @@ describe('# mapValidationResponse', function () {
const result = mapValidationResponse<ValidationErrors, Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isOk()).toBe(true)
expect(result.value).toEqual(RESPONSE)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isOk()).should('be.true')
cy.wrap(result.value).should('equal', RESPONSE)
})
it('should return Either with ValidationError and failed Response', async function () {
@ -59,10 +59,12 @@ describe('# mapValidationResponse', function () {
const result = mapValidationResponse<ValidationErrors, Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isFail()).toBe(true)
expect(result.value).toBeInstanceOf(ValidationError)
expect(result.isFail() && await result.value.getErrors()).toEqual((await RESPONSE.json()).errors)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isFail()).should('be.true')
cy.wrap(result.value).should('be.instanceOf', ValidationError)
const errors = result.isFail() && result.value.getErrors()
const expectErrors = (await RESPONSE.json()).errors
cy.wrap(errors).should('equal', expectErrors)
})
it('should throw NetworkError when Response is failed with status != 422', function () {
@ -71,6 +73,6 @@ describe('# mapValidationResponse', function () {
expect(() => {
mapValidationResponse<ValidationErrors, Partial<Response>>(response)
}).toThrowError(NetworkError)
}).to.throw()
})
})

View File

@ -9,6 +9,6 @@ describe('# params2query', () => {
const result = params2query(params)
expect(result).toEqual('foo=bar&foo2=bar2')
expect(result).to.equal('foo=bar&foo2=bar2')
})
})

View File

@ -1,257 +0,0 @@
import FetchRequest, { FetchRequestOptions } from 'src/utils/request'
import { Either, fail, isEither, success } from 'src/utils/either'
import params2query from 'src/utils/params-to-query'
import mockFetch from 'src/utils/test/mock-fetch'
import wrapTests from 'src/utils/test/wrap-tests'
import { NetworkError } from 'src/types/error'
beforeEach(() => {
mockFetch({ type: 'body' })
})
afterEach(() => {
jest.clearAllMocks()
})
const PREFIX = '/prefix'
const SUB_PREFIX = '/sub-prefix'
const PATH = '/path'
const PARAMS = { q1: 'q1', q2: 'q2' }
type SafeMethod = 'get' | 'delete' | 'checkableGet' | 'checkableDelete'
type UnsafeMethod = 'post' | 'put' | 'patch' | 'checkablePost' | 'checkablePut' | 'checkablePatch'
type Method = SafeMethod | UnsafeMethod
type CheckableSafeMethod = 'checkableGet' | 'checkableDelete'
type CheckableUnsafeMethod = 'checkablePost' | 'checkablePut' | 'checkablePatch'
type CheckableMethod = CheckableSafeMethod | CheckableUnsafeMethod
function isSafe (method: Method): method is SafeMethod {
return ['get', 'delete'].includes(method)
}
function isCheckableSafe (method: CheckableMethod): method is CheckableSafeMethod {
return ['checkableGet', 'checkableDelete'].includes(method)
}
function isCheckable (method: CheckableMethod | Method): method is CheckableMethod {
return ['checkableGet', 'checkableDelete', 'checkablePost', 'checkablePut', 'checkablePatch'].includes(method)
}
async function triggerMethod<T = unknown> (request: FetchRequest, method: Method | CheckableMethod, options?: Partial<FetchRequestOptions>): Promise<T | Either<NetworkError, T>> {
if (isCheckable(method)) {
let response: Either<NetworkError, T>
if (isCheckableSafe(method)) response = await request[method]<T>(PATH, options)
else response = await request[method]<T>(PATH, {}, options)
return response.isOk() ? success(response.value) : fail(response.value)
} else {
let body: T
if (isSafe(method)) body = await request[method]<T>(PATH, options)
else body = await request[method]<T>(PATH, {}, options)
return body
}
}
function forCorrectMethods (task: string, fn: (method: Method) => Promise<void>): void {
wrapTests<Method>({
task,
fn,
list: ['get', 'delete', 'post', 'put', 'patch'],
testName: method => `for method: ${method}`,
})
}
function forCheckableMethods (task: string, fn: (method: CheckableMethod) => Promise<void>): void {
wrapTests<CheckableMethod>({
task,
fn,
list: ['checkableGet', 'checkableDelete', 'checkablePost', 'checkablePut', 'checkablePatch'],
testName: method => `for method: ${method}`,
})
}
function forAllMethods (task: string, fn: (method: Method | CheckableMethod) => Promise<void>): void {
forCheckableMethods(task, fn)
forCorrectMethods(task, fn)
}
forAllMethods('# Should be implemented', async (method) => {
const request = new FetchRequest()
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining({
method: method.replace('checkable', '').toUpperCase(),
}))
})
describe('# Should implement prefix', () => {
forAllMethods('should implement global prefix', async (method) => {
const request = new FetchRequest({ prefix: PREFIX })
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(`${PREFIX}${PATH}`, expect.any(Object))
})
forAllMethods('should implement local prefix', async (method) => {
const request = new FetchRequest()
await triggerMethod(request, method, { prefix: SUB_PREFIX })
expect(global.fetch).toBeCalledWith(`${SUB_PREFIX}${PATH}`, expect.any(Object))
})
forAllMethods('should implement global + local prefix', async (method) => {
const request = new FetchRequest({ prefix: PREFIX })
await triggerMethod(request, method, { prefix: SUB_PREFIX })
expect(global.fetch).toBeCalledWith(`${SUB_PREFIX}${PATH}`, expect.any(Object))
})
})
describe('# Should convert query object to query string in request url', () => {
forAllMethods('should implement global query', async (method) => {
const request = new FetchRequest({ params: PARAMS })
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(PARAMS)}`, expect.any(Object))
})
forAllMethods('should implement local query', async (method) => {
const request = new FetchRequest()
await triggerMethod(request, method, { params: PARAMS })
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(PARAMS)}`, expect.any(Object))
})
forAllMethods('should implement global + local query', async (method) => {
const options = { params: { q1: 'q1', q2: 'q2' } }
const localOptions = { params: { q1: 'q11', q3: 'q3' } }
const expectedOptions = { params: { q1: 'q11', q2: 'q2', q3: 'q3' } }
const request = new FetchRequest(options)
await triggerMethod(request, method, localOptions)
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(expectedOptions.params)}`, expect.any(Object))
})
})
describe('# Should work with headers', function () {
forAllMethods('should add headers', async function (method) {
const options = { headers: { h1: 'h1', h2: 'h2' } }
const request = new FetchRequest(options)
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(options))
})
forAllMethods('should merge headers', async function (method) {
const options = { headers: { h1: 'h1', h2: 'h2' } }
const localOptions = { headers: { h1: 'h11', h3: 'h3' } }
const expectedOptions = { headers: { h1: 'h11', h2: 'h2', h3: 'h3' } }
const request = new FetchRequest(options)
await triggerMethod(request, method, localOptions)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(expectedOptions))
})
})
forCorrectMethods('# Should converted correct response body to json', async function (method) {
const DATA = { foo: 'bar' }
mockFetch({ type: 'body', ...DATA })
const request = new FetchRequest()
const body = await triggerMethod(request, method)
expect(body).toMatchObject(DATA)
})
forCheckableMethods('# Should converted checkable response to Either<NetworkError, DATA_TYPE>', async function (method) {
const DATA = { foo: 'bar' }
interface DATA_TYPE { foo: 'bar' }
mockFetch({ type: 'body', ...DATA })
const request = new FetchRequest()
const result = await triggerMethod<DATA_TYPE>(request, method)
const resultIsEither = isEither<unknown, DATA_TYPE>(result)
const resultIsOk = isEither<unknown, DATA_TYPE>(result) && result.isOk()
const resultValue = isEither<unknown, DATA_TYPE>(result) && result.isOk() ? result.value : null
expect(resultIsEither).toBe(true)
expect(resultIsOk).toBe(true)
expect(resultValue).toMatchObject(DATA)
})
forCorrectMethods('# Should throw NetworkError if correct request is not OK', async function (method) {
mockFetch({
type: 'full',
ok: false,
status: 400,
statusText: 'Bad request',
json: async () => ({}),
})
const request = new FetchRequest()
const result = triggerMethod(request, method)
await expect(result).rejects.toBeInstanceOf(NetworkError)
})
forCheckableMethods('# Should return Either<NetworkError, DATA_TYPE> if checkable request is not OK', async function (method) {
mockFetch({
type: 'full',
ok: false,
status: 400,
statusText: 'Bad request',
json: async () => ({}),
})
const request = new FetchRequest()
const result = await triggerMethod(request, method)
const resultIsEither = isEither<NetworkError, unknown>(result)
const resultIsNotOk = isEither<NetworkError, unknown>(result) && result.isFail()
const resultValue = isEither<NetworkError, unknown>(result) && result.isFail() ? result.value : null
expect(resultIsEither).toBe(true)
expect(resultIsNotOk).toBe(true)
expect(resultValue).toBeInstanceOf(NetworkError)
})
describe('# Authorization header', function () {
const TOKEN = 'token'
const OPTIONS = { headers: { Authorization: `Token ${TOKEN}` } }
forAllMethods('should add authorization header', async function (method) {
const request = new FetchRequest()
request.setAuthorizationHeader(TOKEN)
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(OPTIONS))
})
forAllMethods('should remove authorization header', async function (method) {
const request = new FetchRequest(OPTIONS)
await triggerMethod(request, method)
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(OPTIONS))
request.deleteAuthorizationHeader()
await triggerMethod(request, method)
expect(global.fetch).toBeCalledTimes(2)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining({
headers: {},
}))
})
})

View File

@ -1,57 +0,0 @@
import mockLocalStorage from './test/mock-local-storage'
import Storage from './storage'
describe('# storage', function () {
const DATA = { foo: 'bar' }
const KEY = 'key'
const storage = new Storage<typeof DATA>(KEY)
describe('# GET', function () {
it('should be called with correct key', function () {
const fn = mockLocalStorage('getItem')
storage.get()
expect(fn).toBeCalledWith(KEY)
})
it('should get an object given valid local storage item', function () {
mockLocalStorage('getItem', DATA)
const result = storage.get()
expect(result).toMatchObject(DATA)
})
it('should get null if invalid storage item given', function () {
mockLocalStorage('getItem', '{invalid value}', false)
expect(() => {
const result = storage.get()
expect(result).toBeNull()
}).not.toThrow()
})
})
describe('# SET', () => {
it('should be called with correct key and value', function () {
const fn = mockLocalStorage('setItem')
storage.set(DATA)
expect(fn).toBeCalledWith(KEY, JSON.stringify(DATA))
})
})
describe('# REMOVE', () => {
it('should be called with correct key', function () {
const fn = mockLocalStorage('removeItem')
storage.remove()
expect(fn).toBeCalledWith(KEY)
})
})
})

View File

@ -27,6 +27,12 @@ This is **Strong** content.`,
updatedAt: '2020-01-01T00:00:00Z',
}
const articleAfterFavorite: Article = {
...article,
favorited: true,
favoritesCount: 1,
}
const comment: ArticleComment = {
id: 1,
author,
@ -51,6 +57,7 @@ export default {
author,
user,
article,
articleAfterFavorite,
comment,
comment2,
markdown,

View File

@ -1,27 +0,0 @@
interface FetchResponseBody {
type: 'body'
}
interface FetchResponseFull {
type: 'full'
ok: boolean
status: number
statusText: string
json: () => Promise<unknown>
}
export default function mockFetch (data: FetchResponseBody | FetchResponseFull): void {
let response
const { type, ...body } = data
if (type === 'body') {
response = {
ok: true,
status: 200,
json: async () => body,
}
} else {
response = body
}
global.fetch = jest.fn().mockResolvedValue(response)
}

View File

@ -1,10 +0,0 @@
type LocalStorageKey = 'getItem' | 'setItem' | 'removeItem'
export default function mockLocalStorage<T> (key: LocalStorageKey, data?: T, stringify = true): jest.Mock {
const fn = jest.fn().mockReturnValue(stringify ? JSON.stringify(data) : data)
// use __proto__ because jsdom bug: https://github.com/facebook/jest/issues/6798#issuecomment-412871616
// eslint-disable-next-line no-proto
global.localStorage.__proto__[key] = fn
return fn
}

View File

@ -13,6 +13,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"types": ["cypress", "@testing-library/cypress"]
},
"include": [
"src",

1973
yarn.lock

File diff suppressed because it is too large Load Diff