chore: migrate to vitest

This commit is contained in:
mutoe 2023-09-07 20:13:16 +08:00
parent bb96c96d3b
commit 9dc3e0bd9c
No known key found for this signature in database
GPG Key ID: FEE78A0836900C9C
32 changed files with 4527 additions and 1033 deletions

View File

@ -8,41 +8,46 @@
"build": "vite build",
"serve": "vite preview --port 4137",
"lint:script": "eslint \"{src/**/*.{ts,vue},cypress/**/*.ts}\"",
"lint:tsc": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint:tsc": "vue-tsc --noEmit",
"lint": "concurrently \"npm run lint:tsc\" \"npm run lint:script\"",
"test:unit": "echo \"TODO: migrate to vitest\"",
"test:unit:ci": "echo \"TODO: migrate to vitest\"",
"test:unit": "vitest run --coverage",
"test:e2e": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress open --e2e -c baseUrl=http://localhost:4137\"",
"test:e2e:local": "cypress open --e2e -c baseUrl=http://localhost:5173",
"test:e2e:ci": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e -c baseUrl=http://localhost:4137\"",
"test:e2e:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app",
"test": "npm run test:unit:ci && npm run test:e2e:ci",
"test": "npm run test:unit && npm run test:e2e:ci",
"generate:api": "curl -sL https://raw.githubusercontent.com/gothinkster/realworld/main/api/openapi.yml -o ./src/services/openapi.yml && sta -p ./src/services/openapi.yml -o ./src/services -n api.ts"
},
"dependencies": {
"insane": "^2.6.2",
"marked": "^4.2.5",
"pinia": "^2.0.28",
"vue": "^3.2.45",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@mutoe/eslint-config-preset-vue": "~2.1.2",
"@pinia/testing": "^0.0.14",
"@pinia/testing": "^0.1.3",
"@testing-library/cypress": "^8.0.7",
"@testing-library/vue": "^7.0.0",
"@types/marked": "^4.0.8",
"@vitejs/plugin-vue": "^4.3.4",
"@vitest/coverage-v8": "^0.34.3",
"concurrently": "^7.6.0",
"cypress": "^11.2.0",
"eslint": "^8.31.0",
"eslint-plugin-chai-friendly": "^0.7.2",
"eslint-plugin-cypress": "^2.12.1",
"happy-dom": "^10.11.2",
"husky": "^8.0.2",
"lint-staged": "^13.1.0",
"msw": "^1.3.0",
"rollup-plugin-analyzer": "^4.0.0",
"swagger-typescript-api": "^12.0.2",
"typescript": "~5.0.4",
"vite": "^4.4.9",
"vitest": "^0.34.3",
"vitest-dom": "^0.1.0",
"vue-tsc": "^1.8.8"
},
"lint-staged": {

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,12 @@
import AppFooter from 'src/components/AppFooter.vue'
import { render } from '@testing-library/vue'
import { describe, it, expect } from 'vitest'
import { renderOptions } from 'src/utils/test/test.utils'
import AppFooter from './AppFooter.vue'
describe('# AppFooter', () => {
it('should render correctly', () => {
cy.mount(AppFooter)
const { container } = render(AppFooter, renderOptions())
cy.contains('Real world app')
expect(container).toHaveTextContent('Real world app')
})
})

View File

@ -1,17 +1,18 @@
import { fireEvent, render, waitFor } from '@testing-library/vue'
import { describe, it, expect } from 'vitest'
import { renderOptions } from 'src/utils/test/test.utils.ts'
import AppLink from './AppLink.vue'
describe('# AppLink', () => {
it('should redirect to another page when click the link', () => {
// given
cy.mount(AppLink, {
it('should redirect to another page when click the link', async () => {
const { container, getByRole } = render(AppLink, renderOptions({
props: { name: 'tag', params: { tag: 'foo' } },
slots: { default: () => 'Go to Foo tag' },
})
}))
cy.contains('Go to Foo tag')
expect(container).toHaveTextContent('Go to Foo tag')
await fireEvent.click(getByRole('link', { name: 'tag' }))
cy.contains('tag').click()
// await waitFor(() => expect(linkElement).toHaveClass('router-link-active'))
await waitFor(() => expect(getByRole('link', { name: 'tag' })).toHaveClass('router-link-active'))
})
})

View File

@ -1,32 +1,27 @@
import { createPinia, setActivePinia } from 'pinia'
import { useUserStore } from 'src/store/user'
import { render } from '@testing-library/vue'
import { expect, describe, it } from 'vitest'
import { renderOptions } from 'src/utils/test/test.utils.ts'
import AppNavigation from './AppNavigation.vue'
describe('# AppNavigation', () => {
beforeEach(async () => {
setActivePinia(createPinia())
})
it('should render Sign in and Sign up when user not logged', () => {
const userStore = useUserStore()
userStore.updateUser(null)
cy.mount(AppNavigation)
const { getByRole } = render(AppNavigation, renderOptions())
cy.get('.nav-item').should('have.length', 3)
cy.contains('Home')
cy.contains('Sign in')
cy.contains('Sign up')
expect(getByRole('link', { name: 'Home' })).toHaveTextContent('Home')
expect(getByRole('link', { name: 'Sign in' })).toHaveTextContent('Sign in')
expect(getByRole('link', { name: 'Sign up' })).toHaveTextContent('Sign up')
})
it('should render xxx when user logged', () => {
const userStore = useUserStore()
userStore.updateUser({ username: 'foo', email: '', token: '', bio: '', image: '' })
cy.mount(AppNavigation)
const { getByRole } = render(AppNavigation, renderOptions({
initialState: {
user: { user: { username: 'username', email: '', token: '', bio: '', image: '' } },
},
}))
cy.get('.nav-item').should('have.length', 4)
cy.contains('Home')
cy.contains('New Post')
cy.contains('Settings')
cy.contains('foo')
expect(getByRole('link', { name: 'Home' })).toHaveTextContent('Home')
expect(getByRole('link', { name: 'New Post' })).toHaveTextContent('New Post')
expect(getByRole('link', { name: 'Settings' })).toHaveTextContent('Settings')
expect(getByRole('link', { name: 'username' })).toHaveTextContent('username')
})
})

View File

@ -19,6 +19,7 @@
active-class="active"
:name="link.name"
:params="link.params"
:aria-label="link.title"
>
<i
v-if="link.icon"

View File

@ -1,25 +1,26 @@
import { mount } from 'cypress/vue'
import { fireEvent, render } from '@testing-library/vue'
import { renderOptions } from 'src/utils/test/test.utils.ts'
import { describe, it, vi, expect } from 'vitest'
import AppPagination from './AppPagination.vue'
describe('# AppPagination', () => {
it('should highlight current active page', () => {
cy.mount(AppPagination, {
const { getByRole } = render(AppPagination, renderOptions({
props: { page: 1, count: 15 },
})
}))
cy.get('.page-item').should('have.length', 2)
.eq(0).should('have.class', 'active')
expect(getByRole('link', { name: 'Go to page 1' }).parentNode).toHaveClass('active')
expect(getByRole('link', { name: 'Go to page 2' }).parentNode).not.toHaveClass('active')
})
it('should call onPageChange when click a page item', () => {
const onPageChange = cy.spy().as('onPageChange')
mount(AppPagination, {
it('should call onPageChange when click a page item', async () => {
const onPageChange = vi.fn()
const { getByRole } = render(AppPagination, renderOptions({
props: { page: 1, count: 15, onPageChange },
})
}))
cy.findByRole('link', { name: 'Go to page 2' })
.click()
await fireEvent.click(getByRole('link', { name: 'Go to page 2' }))
cy.get('@onPageChange').should('have.been.calledWith', 2)
expect(onPageChange).toHaveBeenCalledWith(2)
})
})

View File

@ -1,51 +1,40 @@
import { createPinia, setActivePinia } from 'pinia'
import { render } from '@testing-library/vue'
import fixtures from 'src/utils/test/fixtures'
import { asyncWrapper, createTestRouter } from 'src/utils/test/test.utils'
import { asyncWrapper, renderOptions, setupMockServer } from 'src/utils/test/test.utils'
import { describe, expect, it } from 'vitest'
import ArticleDetail from './ArticleDetail.vue'
describe('# ArticleDetail', () => {
const router = createTestRouter()
const AsyncArticleDetail = asyncWrapper(ArticleDetail)
const server = setupMockServer(
['/api/articles/markdown', { article: { ...fixtures.article, body: fixtures.markdown } }],
['/api/articles/markdown-cn', { article: { ...fixtures.article, body: fixtures.markdownCN } }],
['/api/articles/markdown-xss', { article: { ...fixtures.article, body: fixtures.markdownXss } }],
)
beforeEach(() => {
setActivePinia(createPinia())
cy.wrap(router.push({ name: 'article', params: { slug: fixtures.article.slug } }))
it('should render markdown body correctly', async () => {
const { container } = render(asyncWrapper(ArticleDetail), await renderOptions({
initialRoute: { name: 'article', params: { slug: 'markdown' } },
}))
await server.waitForRequest('GET', '/api/articles/markdown')
expect(container).toMatchSnapshot()
})
it('should render markdown body correctly', () => {
cy.fixture('article.json').then((res) => {
res.article.body = fixtures.markdown
cy.intercept('/api/articles/*', res).as('getArticle')
})
it('should render markdown (zh-CN) body correctly', async () => {
const { container } = render(asyncWrapper(ArticleDetail), await renderOptions({
initialRoute: { name: 'article', params: { slug: 'markdown-cn' } },
}))
await server.waitForRequest('GET', '/api/articles/markdown-cn')
cy.mount(AsyncArticleDetail, { router })
cy.wait('@getArticle')
cy.get('.article-content').should('contain.text', 'h1 Heading 8-)')
expect(container).toMatchSnapshot()
})
// TODO: the markdown content should do the unit test for the markdown renderer
it.skip('should render markdown (zh-CN) body correctly', () => {
cy.fixture('article.json').then((body) => {
body.article.body = fixtures.markdownCN
cy.intercept('/api/articles/*', body).as('getArticle')
})
it('should filter the xss content in Markdown body', async () => {
const { container } = render(asyncWrapper(ArticleDetail), await renderOptions({
initialRoute: { name: 'article', params: { slug: 'markdown-xss' } },
}))
await server.waitForRequest('GET', '/api/articles/markdown-xss')
cy.mount(AsyncArticleDetail, { router })
cy.wait('@getArticle')
cy.get('.article-content').should('have.text', fixtures.markdownCN)
})
it.skip('should filter the xss content in Markdown body', () => {
cy.fixture('article.json').then((body) => {
body.article.body = fixtures.markdownXss
cy.intercept('/api/articles/*', body).as('getArticle')
})
cy.mount(AsyncArticleDetail, { router })
cy.wait('@getArticle')
cy.get('.article-content').should('have.text', fixtures.markdownXss)
expect(container).toMatchSnapshot()
})
})

View File

@ -1,40 +1,43 @@
import { fireEvent, render } from '@testing-library/vue'
import fixtures from 'src/utils/test/fixtures'
import { renderOptions } from 'src/utils/test/test.utils.ts'
import { vi, describe, it, expect } from 'vitest'
import ArticleDetailComment from './ArticleDetailComment.vue'
describe('# ArticleDetailComment', () => {
it('should render correctly', () => {
cy.mount(ArticleDetailComment, {
const { container, queryByRole } = render(ArticleDetailComment, renderOptions({
props: { comment: fixtures.comment },
})
}))
cy.get('.card-text').should('have.text', 'Comment body')
cy.get('.date-posted').should('have.text', '1/1/2020')
cy.findByRole('button', { name: 'Delete comment' }).should('not.exist')
expect(container).toHaveTextContent('Comment body')
expect(container).toHaveTextContent('1/1/2020')
expect(queryByRole('button', { name: 'Delete comment' })).not.toBeInTheDocument()
})
it('should delete comment button when comment author is same user', () => {
cy.mount(ArticleDetailComment, {
const { getByRole } = render(ArticleDetailComment, renderOptions({
props: {
comment: fixtures.comment,
username: fixtures.author.username,
},
})
}))
cy.findByRole('button', { name: 'Delete comment' })
expect(getByRole('button', { name: 'Delete comment' })).toBeInTheDocument()
})
it('should emit remove comment when click remove comment button', () => {
const onRemoveComment = cy.spy().as('onRemoveComment')
cy.mount(ArticleDetailComment, {
const onRemoveComment = vi.fn()
const { getByRole } = render(ArticleDetailComment, renderOptions({
props: {
comment: fixtures.comment,
username: fixtures.author.username,
onRemoveComment,
},
})
}))
cy.findByRole('button', { name: 'Delete comment' }).click()
fireEvent.click(getByRole('button', { name: 'Delete comment' }))
cy.get('@onRemoveComment').should('have.been.called')
expect(onRemoveComment).toHaveBeenCalled()
})
})

View File

@ -29,7 +29,7 @@
{{ comment.author.username }}
</AppLink>
<span class="date-posted">{{ (new Date(comment.createdAt)).toLocaleDateString() }}</span>
<span class="date-posted">{{ (new Date(comment.createdAt)).toLocaleDateString('en-US') }}</span>
<span class="mod-options">
<i

View File

@ -1,61 +1,55 @@
import { createPinia, setActivePinia } from 'pinia'
import { useUserStore } from 'src/store/user'
import { fireEvent, render } from '@testing-library/vue'
import fixtures from 'src/utils/test/fixtures'
import { asyncWrapper, createTestRouter } from 'src/utils/test/test.utils'
import { asyncWrapper, renderOptions, setupMockServer } from 'src/utils/test/test.utils'
import { describe, expect, it } from 'vitest'
import ArticleDetailComments from './ArticleDetailComments.vue'
describe('# ArticleDetailComments', () => {
// const mockDeleteComment = deleteComment as jest.MockedFunction<typeof deleteComment>
const AsyncArticleDetailComments = asyncWrapper(ArticleDetailComments)
const router = createTestRouter()
setActivePinia(createPinia())
const userStore = useUserStore()
beforeEach(() => {
cy.intercept('GET', '/api/profiles/*', { profile: fixtures.author }).as('getProfile')
cy.intercept('GET', '/api/articles/*/comments', { comments: [fixtures.comment] }).as('getCommentsByArticle')
cy.intercept('POST', '/api/articles/*/comments', { comment: fixtures.comment2 }).as('postCommentsByArticle')
cy.wrap(router.push({ name: 'article', params: { slug: fixtures.article.slug } }))
const server = setupMockServer(
['GET', '/api/profiles/*', { profile: fixtures.author }],
['GET', '/api/articles/*/comments', { comments: [fixtures.comment] }],
['POST', '/api/articles/*/comments', { comment: fixtures.comment2 }],
['DELETE', '/api/articles/*/comments/*'],
)
it('should render correctly', async () => {
const { container } = render(asyncWrapper(ArticleDetailComments), await renderOptions({
initialRoute: { name: 'article', params: { slug: fixtures.article.slug } },
initialState: { user: { user: null } },
}))
await server.waitForRequest('GET', '/api/articles/article-foo/comments')
expect(container).toHaveTextContent('Comment body')
})
it('should render correctly', () => {
cy.mount(AsyncArticleDetailComments, { router })
it('should display new comment when post new comment', async () => {
const { container, getByRole } = render(asyncWrapper(ArticleDetailComments), await renderOptions({
initialRoute: { name: 'article', params: { slug: fixtures.article.slug } },
initialState: { user: { user: fixtures.user } },
}))
await server.waitForRequest('GET', '/api/articles/*/comments')
expect(container).toHaveTextContent('Comment body')
cy.wait('@getCommentsByArticle')
.its('request.url')
.should('contain', '/api/articles/article-foo/comments')
await fireEvent.update(getByRole('textbox', { name: 'Write comment' }), fixtures.comment2.body)
await fireEvent.click(getByRole('button', { name: 'Submit' }))
cy.contains(fixtures.comment.body)
await server.waitForRequest('POST', '/api/articles/*/comments')
expect(container).toHaveTextContent(fixtures.comment2.body)
})
it('should display new comment when post new comment', () => {
// given
userStore.updateUser(fixtures.user)
cy.mount(AsyncArticleDetailComments, { router })
cy.wait('@getProfile')
cy.wait('@getCommentsByArticle')
cy.contains(fixtures.comment.body)
it('should call remove comment service when click delete button', async () => {
const { container, getByRole } = render(asyncWrapper(ArticleDetailComments), await renderOptions({
initialRoute: { name: 'article', params: { slug: fixtures.article.slug } },
initialState: { user: { user: fixtures.user } },
}))
await server.waitForRequest('GET', '/api/articles/article-foo/comments')
// when
cy.findByRole('textbox', { name: 'Write comment' }).type(fixtures.comment2.body)
cy.findByRole('button', { name: 'Submit' }).click()
await fireEvent.click(getByRole('button', { name: 'Delete comment' }))
// then
cy.contains(fixtures.comment2.body)
})
it.only('should call remove comment service when click delete button', () => {
// given
cy.intercept('DELETE', '/api/articles/*/comments/*', { status: 200 }).as('deleteComment')
userStore.updateUser(fixtures.user)
cy.mount(AsyncArticleDetailComments, { router })
cy.wait('@getCommentsByArticle')
// when
cy.findByRole('button', { name: 'Delete comment' }).click()
// then
cy.wait('@deleteComment')
cy.contains(fixtures.comment.body).should('not.exist')
await server.waitForRequest('DELETE', '/api/articles/article-foo/comments/*')
expect(container).not.toHaveTextContent(fixtures.comment.body)
})
})

View File

@ -1,37 +1,34 @@
import { createPinia, setActivePinia } from 'pinia'
import { fireEvent, render } from '@testing-library/vue'
import ArticleDetailCommentsForm from 'src/components/ArticleDetailCommentsForm.vue'
import { useUserStore } from 'src/store/user'
import fixtures from 'src/utils/test/fixtures'
import { renderOptions, setupMockServer } from 'src/utils/test/test.utils.ts'
import { describe, expect, it } from 'vitest'
describe('# ArticleDetailCommentsForm', () => {
setActivePinia(createPinia())
const userStore = useUserStore()
beforeEach(() => {
cy.intercept('/api/profiles/*', { profile: fixtures.author }).as('getProfile')
cy.intercept('POST', '/api/articles/*/comments', { comment: { body: 'some texts...' } }).as('postComment')
userStore.updateUser(fixtures.user)
})
const server = setupMockServer(
['POST', '/api/articles/*/comments', { comment: { body: 'some texts...' } }],
)
it('should display sign in button when user not logged', () => {
userStore.updateUser(null)
cy.mount(ArticleDetailCommentsForm, {
const { container } = render(ArticleDetailCommentsForm, renderOptions({
initialState: { user: { user: null } },
props: { articleSlug: fixtures.article.slug },
})
}))
cy.contains('add comments on this article')
expect(container).toHaveTextContent('add comments on this article')
})
it('should display form when user logged', () => {
cy.mount(ArticleDetailCommentsForm, {
it('should display form when user logged', async () => {
server.use(['GET', '/api/profiles/*', { profile: fixtures.author }])
const { getByRole } = render(ArticleDetailCommentsForm, renderOptions({
initialState: { user: { user: fixtures.user } },
props: { articleSlug: fixtures.article.slug },
})
}))
await server.waitForRequest('GET', '/api/profiles/*')
cy.findByRole('textbox', { name: 'Write comment' }).type('some texts...')
cy.findByRole('button', { name: 'Submit' }).click()
await fireEvent.update(getByRole('textbox', { name: 'Write comment' }), 'some texts...')
await fireEvent.click(getByRole('button', { name: 'Submit' }))
cy.wait('@postComment')
.its('request.body')
.should('deep.equal', { comment: { body: 'some texts...' } })
await server.waitForRequest('POST', '/api/articles/*/comments')
})
})

View File

@ -1,7 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { fireEvent, render } from '@testing-library/vue'
import type { Profile } from 'src/services/api'
import { useUserStore } from 'src/store/user'
import fixtures from 'src/utils/test/fixtures'
import { renderOptions, setupMockServer } from 'src/utils/test/test.utils.ts'
import { describe, expect, it, vi } from 'vitest'
import ArticleDetailMeta from './ArticleDetailMeta.vue'
const editButton = 'Edit article'
@ -12,119 +13,105 @@ const favoriteButton = 'Favorite article'
const unfavoriteButton = 'Unfavorite article'
describe('# ArticleDetailMeta', () => {
setActivePinia(createPinia())
const userStore = useUserStore()
beforeEach(() => {
userStore.updateUser(fixtures.user)
})
const server = setupMockServer()
it('should display edit button when user is author', () => {
cy.mount(ArticleDetailMeta, {
const { getByRole, queryByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: fixtures.user } },
props: { article: fixtures.article },
})
}))
cy.findByRole('link', { name: editButton })
cy.findByRole('button', { name: followButton }).should('not.exist')
expect(getByRole('link', { name: editButton })).toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).not.toBeInTheDocument()
})
it('should display follow button when user not author', () => {
userStore.updateUser({ ...fixtures.user, username: 'user2' })
cy.mount(ArticleDetailMeta, {
const { getByRole, queryByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: { ...fixtures.user, username: 'user2' } } },
props: { article: fixtures.article },
})
}))
cy.findByRole('link', { name: editButton }).should('not.exist')
cy.findByRole('button', { name: followButton })
expect(getByRole('button', { name: followButton })).toBeInTheDocument()
expect(queryByRole('link', { name: editButton })).not.toBeInTheDocument()
})
it('should not display follow button and edit button when user not logged', () => {
userStore.updateUser(null)
cy.mount(ArticleDetailMeta, {
const { queryByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: null } },
props: { article: fixtures.article },
})
}))
cy.findByRole('link', { name: editButton }).should('not.exist')
cy.findByRole('button', { name: followButton }).should('not.exist')
expect(queryByRole('button', { name: editButton })).not.toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).not.toBeInTheDocument()
})
it('should call delete article service when click delete button', () => {
cy.intercept('DELETE', '/api/articles/*', { status: 200 }).as('deleteArticle')
cy.mount(ArticleDetailMeta, {
it('should call delete article service when click delete button', async () => {
server.use(['DELETE', '/api/articles/*'])
const { getByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: fixtures.user } },
props: { article: fixtures.article },
})
}))
cy.findByRole('button', { name: deleteButton }).click()
await fireEvent.click(getByRole('button', { name: deleteButton }))
cy.wait('@deleteArticle')
.its('request.url')
.should('contain', '/api/articles/article-foo')
await server.waitForRequest('DELETE', '/api/articles/*')
})
it('should call follow service when click follow button', () => {
it('should call follow service when click follow button', async () => {
const newProfile: Profile = { ...fixtures.user, following: true }
cy.intercept('POST', '/api/profiles/*/follow', { profile: newProfile }).as('followUser')
userStore.updateUser({ ...fixtures.user, username: 'user2' })
const onUpdate = cy.spy().as('onUpdate')
cy.mount(ArticleDetailMeta, {
server.use(['POST', '/api/profiles/*/follow', { profile: newProfile }])
const onUpdate = vi.fn()
const { getByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: { ...fixtures.user, username: 'user2' } } },
props: { article: fixtures.article, onUpdate },
})
}))
cy.findByRole('button', { name: followButton }).click()
await fireEvent.click(getByRole('button', { name: followButton }))
cy.get('@followUser')
.its('request.url')
.should('contain', '/api/profiles/Author%20name/follow')
cy.get('@onUpdate').should('be.calledWith', { ...fixtures.article, author: newProfile })
await server.waitForRequest('POST', '/api/profiles/*/follow')
expect(onUpdate).toHaveBeenCalledWith({ ...fixtures.article, author: newProfile })
})
it('should call unfollow service when click follow button and not followed author', () => {
it('should call unfollow service when click follow button and not followed author', async () => {
const newProfile: Profile = { ...fixtures.user, following: false }
cy.intercept('DELETE', '/api/profiles/*/follow', { profile: newProfile }).as('unfollowUser')
userStore.updateUser({ ...fixtures.user, username: 'user2' })
const onUpdate = cy.spy().as('onUpdate')
cy.mount(ArticleDetailMeta, {
server.use(['DELETE', '/api/profiles/*/follow', { profile: newProfile }])
const onUpdate = vi.fn()
const { getByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: { ...fixtures.user, username: 'user2' } } },
props: {
article: { ...fixtures.article, author: { ...fixtures.article.author, following: true } },
onUpdate,
},
})
}))
cy.findByRole('button', { name: unfollowButton }).click()
await fireEvent.click(getByRole('button', { name: unfollowButton }))
cy.wait('@unfollowUser')
.its('request.url')
.should('contain', '/api/profiles/Author%20name/follow')
await server.waitForRequest('DELETE', '/api/profiles/*/follow')
cy.get('@onUpdate').should('be.calledWith', { ...fixtures.article, author: newProfile })
expect(onUpdate).toHaveBeenCalledWith({ ...fixtures.article, author: newProfile })
})
it('should call favorite article service when click favorite button', () => {
cy.intercept('POST', '/api/articles/*/favorite', { status: 200 }).as('favoriteArticle')
userStore.updateUser({ ...fixtures.user, username: 'user2' })
cy.mount(ArticleDetailMeta, {
it('should call favorite article service when click favorite button', async () => {
server.use(['POST', '/api/articles/*/favorite', { article: { ...fixtures.article, favorited: true } }])
const { getByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: { ...fixtures.user, username: 'user2' } } },
props: { article: { ...fixtures.article, favorited: false } },
})
}))
cy.findByRole('button', { name: favoriteButton }).click()
await fireEvent.click(getByRole('button', { name: favoriteButton }))
cy.wait('@favoriteArticle')
.its('request.url')
.should('contain', '/api/articles/article-foo/favorite')
await server.waitForRequest('POST', '/api/articles/*/favorite')
})
it('should call favorite article service when click unfavorite button', () => {
cy.intercept('DELETE', '/api/articles/*/favorite', { status: 200 }).as('unfavoriteArticle')
userStore.updateUser({ ...fixtures.user, username: 'user2' })
cy.mount(ArticleDetailMeta, {
it('should call favorite article service when click unfavorite button', async () => {
server.use(['DELETE', '/api/articles/*/favorite', { article: { ...fixtures.article, favorited: false } }])
const { getByRole } = render(ArticleDetailMeta, renderOptions({
initialState: { user: { user: { ...fixtures.user, username: 'user2' } } },
props: { article: { ...fixtures.article, favorited: true } },
})
}))
cy.findByRole('button', { name: unfavoriteButton }).click()
await fireEvent.click(getByRole('button', { name: unfavoriteButton }))
cy.wait('@unfavoriteArticle')
.its('request.url')
.should('contain', '/api/articles/article-foo/favorite')
await server.waitForRequest('DELETE', '/api/articles/*/favorite')
})
})

View File

@ -1,23 +1,21 @@
import { createPinia, setActivePinia } from 'pinia'
import { render } from '@testing-library/vue'
import ArticlesList from 'src/components/ArticlesList.vue'
import fixtures from 'src/utils/test/fixtures'
import { asyncWrapper } from 'src/utils/test/test.utils'
import { asyncWrapper, renderOptions, setupMockServer } from 'src/utils/test/test.utils'
import { describe, it, expect } from 'vitest'
describe('# ArticlesList', () => {
const AsyncArticlesList = asyncWrapper(ArticlesList)
setActivePinia(createPinia())
const server = setupMockServer(
['GET', '/api/articles*', { articles: [fixtures.article], articlesCount: 1 }],
)
beforeEach(() => {
cy.intercept('GET', '/api/articles*', { articles: [fixtures.article], articlesCount: 1 }).as('getArticles')
})
it('should render correctly', async () => {
const { container } = render(asyncWrapper(ArticlesList), renderOptions())
it('should render correctly', () => {
cy.mount(AsyncArticlesList)
await server.waitForRequest('GET', '/api/articles*')
cy.wait('@getArticles')
cy.contains(fixtures.article.title)
cy.contains('Article description')
cy.contains(fixtures.article.author.username)
expect(container).toHaveTextContent(fixtures.article.title)
expect(container).toHaveTextContent('Article description')
expect(container).toHaveTextContent(fixtures.article.author.username)
})
})

View File

@ -1,17 +1,22 @@
import { fireEvent, render } from '@testing-library/vue'
import ArticlesListArticlePreview from 'src/components/ArticlesListArticlePreview.vue'
import fixtures from 'src/utils/test/fixtures'
import { renderOptions, setupMockServer } from 'src/utils/test/test.utils.ts'
import { describe, it } from 'vitest'
const favoriteButton = 'Favorite article'
describe('# ArticlesListArticlePreview', () => {
it('should call favorite method when click favorite button', () => {
cy.intercept('POST', '/api/articles/*/favorite', { status: 200 }).as('favoriteArticle')
cy.mount(ArticlesListArticlePreview, {
const server = setupMockServer()
it('should call favorite method when click favorite button', async () => {
server.use(['POST', '/api/articles/*/favorite', { article: { ...fixtures.article, favorited: true } }])
const { getByRole } = render(ArticlesListArticlePreview, renderOptions({
props: { article: fixtures.article },
})
}))
cy.findByRole('button', { name: favoriteButton }).click()
await fireEvent.click(getByRole('button', { name: favoriteButton }))
cy.wait('@favoriteArticle')
await server.waitForRequest('POST', '/api/articles/*/favorite')
})
})

View File

@ -1,37 +1,27 @@
import { createPinia, setActivePinia } from 'pinia'
import { render } from '@testing-library/vue'
import ArticlesListNavigation from 'src/components/ArticlesListNavigation.vue'
import { useUserStore } from 'src/store/user'
import fixtures from 'src/utils/test/fixtures'
import { renderOptions } from 'src/utils/test/test.utils.ts'
import { describe, expect, it } from 'vitest'
describe('# ArticlesListNavigation', () => {
setActivePinia(createPinia())
const userStore = useUserStore()
beforeEach(async () => {
userStore.updateUser(fixtures.user)
})
it('should render global feed item when passed global feed prop', () => {
cy.mount(ArticlesListNavigation, {
const { container } = render(ArticlesListNavigation, renderOptions({
initialState: { user: { user: fixtures.user } },
props: { tag: '', username: '', useGlobalFeed: true },
})
}))
cy.get('.nav-item')
.should('have.length', 1)
.contains('Global Feed')
expect(container).toHaveTextContent('Global Feed')
})
it('should render full item', () => {
cy.mount(ArticlesListNavigation, {
const { container } = render(ArticlesListNavigation, renderOptions({
initialState: { user: { user: fixtures.user } },
props: { tag: 'foo', username: '', useGlobalFeed: true, useMyFeed: true, useTagFeed: true },
})
}))
cy.get('.nav-item')
.should('have.length', 3)
.should(elements => {
expect(elements).to.contain('Global Feed')
expect(elements).to.contain('Your Feed')
expect(elements).to.contain('foo')
})
expect(container).toHaveTextContent('Global Feed')
expect(container).toHaveTextContent('Your Feed')
expect(container).toHaveTextContent('foo')
})
})

View File

@ -1,18 +1,20 @@
import { render } from '@testing-library/vue'
import PopularTags from 'src/components/PopularTags.vue'
import { asyncWrapper } from 'src/utils/test/test.utils'
import { asyncWrapper, renderOptions, setupMockServer } from 'src/utils/test/test.utils'
import { describe, expect, it } from 'vitest'
describe('# PopularTags', () => {
const AsyncPopularTags = asyncWrapper(PopularTags)
const server = setupMockServer(
['GET', '/api/tags', { tags: ['tag1', 'tag2'] }],
)
beforeEach(() => {
cy.intercept('GET', '/api/tags', { tags: ['foo', 'bar'] }).as('getTags')
})
it('should render correctly', async () => {
const { getAllByRole } = render(asyncWrapper(PopularTags), renderOptions())
it('should render correctly', () => {
cy.mount(AsyncPopularTags)
await server.waitForRequest('GET', '/api/tags')
cy.get('.tag-pill')
.should('have.length', 2)
.contains('foo')
expect(getAllByRole('link')).toHaveLength(2)
expect(getAllByRole('link')[0]).toHaveTextContent('tag1')
expect(getAllByRole('link')[1]).toHaveTextContent('tag2')
})
})

View File

@ -7,6 +7,7 @@
:key="tag"
name="tag"
:params="{tag}"
:aria-label="tag"
class="tag-pill tag-default"
>
{{ tag }}
@ -16,6 +17,7 @@
<script setup lang="ts">
import { useTags } from 'src/composable/useTags'
const { tags, fetchTags } = useTags()
await fetchTags()

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
/// <reference types="vite/client" />
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
}

View File

@ -1,26 +1,31 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestRouter } from 'src/utils/test/test.utils'
import { render } from '@testing-library/vue'
import fixtures from 'src/utils/test/fixtures.ts'
import { renderOptions, setupMockServer } from 'src/utils/test/test.utils'
import { describe, expect, it } from 'vitest'
import Article from './Article.vue'
describe('# Article', () => {
const router = createTestRouter()
const server = setupMockServer(
['GET', '/api/articles/foo', { article: fixtures.article }],
['GET', '/api/articles/foo/comments', { comments: fixtures.articleComments }],
)
beforeEach(() => {
setActivePinia(createPinia())
cy.wrap(router.push({ name: 'article', params: { slug: 'foo' } }))
cy.intercept('GET', '/api/articles/foo', { fixture: 'article.json' }).as('getArticle')
cy.intercept('GET', '/api/articles/foo/comments', { fixture: 'article-comments.json' }).as('getComments')
})
it('should render correctly', async () => {
const { container } = render(Article, await renderOptions({
initialRoute: { name: 'article', params: { slug: 'foo' } },
}))
it('should render correctly', () => {
cy.mount(Article, { router })
expect(container).toHaveTextContent('Article is downloading')
expect(container).toHaveTextContent('Comments are downloading')
cy.contains('Article is downloading')
cy.contains('Comments are downloading')
await server.waitForRequest('GET', '/api/articles/foo')
cy.wait('@getArticle')
cy.contains('Article title')
cy.contains('Before starting a new implementation')
expect(container).toHaveTextContent(fixtures.article.title)
expect(container).toHaveTextContent('Article body')
expect(container).toHaveTextContent(fixtures.article.author.username)
expect(container).toHaveTextContent(fixtures.articleComments[0].body)
expect(container).toHaveTextContent(fixtures.articleComments[0].author.username)
expect(container).toHaveTextContent(fixtures.articleComments[1].body)
expect(container).toHaveTextContent(fixtures.articleComments[1].author.username)
})
})

6
src/setupTests.ts Normal file
View File

@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="vitest-dom/extend-expect" />
import 'vitest-dom/extend-expect'
// https://github.com/mswjs/msw/issues/1415#issuecomment-1650562700
location.href = 'https://api.realworld.io/'

View File

@ -1,10 +1,11 @@
import { dateFilter } from 'src/utils/filters'
import { expect, describe, it } from 'vitest'
describe('# Date filters', () => {
it('should format date correctly', () => {
const dateString = '2019-01-01 00:00:00'
const result = dateFilter(dateString)
expect(result).to.equal('January 1')
expect(result).toBe('January 1')
})
})

View File

@ -1,3 +1,4 @@
import { describe, it, expect } from 'vitest'
import params2query from './params-to-query'
describe('# params2query', () => {
@ -9,6 +10,6 @@ describe('# params2query', () => {
const result = params2query(params)
expect(result).to.equal('foo=bar&foo2=bar2')
expect(result).toEqual('foo=bar&foo2=bar2')
})
})

View File

@ -1,3 +1,4 @@
import { describe, it, beforeAll, expect } from 'vitest'
import Storage from './storage'
describe('# Storage', () => {
@ -6,23 +7,23 @@ describe('# Storage', () => {
const storage = new Storage<typeof DATA>(KEY)
before(() => {
beforeAll(() => {
storage.remove()
})
it('should be called with correct key', () => {
expect(storage.get()).to.be.null
expect(storage.get()).toBeNull()
})
it('should be set value correctly', () => {
storage.set(DATA)
expect(storage.get()).to.deep.equal(DATA)
expect(storage.get()).toEqual(DATA)
})
it('should be remove correctly', () => {
storage.remove()
expect(storage.get()).to.be.null
expect(storage.get()).toBeNull()
})
})

File diff suppressed because one or more lines are too long

View File

@ -1,19 +1,198 @@
/* eslint-disable unicorn/no-nested-ternary */
import type { MockedRequest, DefaultBodyType } from 'msw'
import { matchRequestUrl, rest } from 'msw'
import type { SetupServer } from 'msw/node'
import { setupServer } from 'msw/node'
import { routes } from 'src/router'
import type { DefineComponent } from 'vue'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { defineComponent, h, Suspense } from 'vue'
import type { Router } from 'vue-router'
import type { RouteLocationRaw, Router } from 'vue-router'
import { createMemoryHistory, createRouter } from 'vue-router'
import type { RenderOptions } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import AppLink from 'src/components/AppLink.vue'
export const createTestRouter = (): Router => createRouter({
export const createTestRouter = (base?: string): Router => createRouter({
routes,
history: createMemoryHistory(),
history: createMemoryHistory(base),
})
type AsyncWrapper = (...args: Parameters<typeof h>) => DefineComponent
export const asyncWrapper: AsyncWrapper = (...args) => defineComponent({
render () {
return h(Suspense, null, {
default: h(...args),
interface RenderOptionsArgs {
props: Record<string, unknown>
slots: Record<string, (...args: unknown[]) => unknown>
initialRoute: RouteLocationRaw
initialState: Record<string, unknown>
}
const scheduler = typeof setImmediate === 'function' ? setImmediate : setTimeout
export function flushPromises (): Promise<void> {
return new Promise((resolve) => {
scheduler(resolve, 0)
})
}
export function renderOptions (): RenderOptions
export function renderOptions (args: Partial<Omit<RenderOptionsArgs, 'initialRoute'>>): RenderOptions
export async function renderOptions (args: (Partial<RenderOptionsArgs> & {initialRoute: RouteLocationRaw})): Promise<RenderOptions>
export function renderOptions (args: Partial<RenderOptionsArgs> = {}): RenderOptions | Promise<RenderOptions> {
const router = createTestRouter()
const result = {
props: args.props,
slots: args.slots,
global: {
plugins: [
router,
createTestingPinia({
initialState: {
user: { user: null },
...args.initialState,
},
})],
components: { AppLink },
},
}
const { initialRoute } = args
if (!initialRoute) return result
return new Promise((resolve) => {
router.replace(initialRoute).then(() => resolve(result))
})
}
export function asyncWrapper (component: ReturnType<typeof defineComponent>, props?: Record<string, unknown>): ReturnType<typeof defineComponent> {
return defineComponent({
render () {
return h(
'div',
{ id: 'root' },
h(Suspense, null, {
default () {
return h(component, props)
},
fallback: h('div', 'Loading...'),
}),
)
},
})
}
async function waitForServerRequest (server: SetupServer, method: string, url: string, flush = true) {
let requestId = ''
let request: MockedRequest
const result = await new Promise<MockedRequest>((resolve, reject) => {
server.events.on('request:start', (req) => {
const matchesMethod = req.method.toLowerCase() === method.toLowerCase()
const matchesUrl = matchRequestUrl(req.url, url).matches
if (matchesMethod && matchesUrl) {
requestId = req.id
request = req
}
})
},
})
server.events.on('response:mocked', (_res, reqId) => {
if (reqId === requestId) resolve(request)
})
server.events.on('request:unhandled', (req) => {
if (req.id === requestId) reject(new Error(`The ${req.method} ${req.url.href} request was unhandled.`))
})
})
flush && await flushPromises()
return result
}
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'all' | 'ALL'
type Listener =
| [HttpMethod, string, number, object]
| [HttpMethod, string, number]
| [HttpMethod, string, object]
| [string, number, object]
| [HttpMethod, string]
| [string, object]
| [string]
/**
* Sets up a mock server with provided listeners.
*
* @example
* const server = setupMockServer(
* ['/api/articles/markdown', { article }],
* ['/api/articles/markdown', 200, { article }],
* ['GET', '/api/articles/markdown', { article }],
* ['GET', '/api/articles/markdown', 200, { article }],
* ['DELETE', '/api/articles/comment'],
* ['DELETE', '/api/articles/comment', 204],
* )
*
* it('...', async () => {
* await server.waitForRequest('/api/articles/markdown')
* await server.waitForRequest('GET', '/api/articles/markdown')
* })
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function setupMockServer (...listeners: Listener[]) {
const parseArgs = (args: Listener): [string, string, number, (object | null)] => {
if (args.length === 4) return args
if (args.length === 3) {
if (typeof args[1] === 'number') return ['all', args[0], args[1], args[2] as object] // ['all', path, 200, object]
if (typeof args[2] === 'number') return [args[0], args[1], args[2], null] // [method, path, status, null]
return [args[0], args[1], 200, args[2]] // [method, path, 200, object]
}
if (args.length === 2) {
if (typeof args[1] === 'string') return [args[0], args[1], 200, null]
return ['all', args[0], 200, args[1]]
}
return ['all', args[0], 200, null]
}
const server = setupServer(
...listeners.map((args) => {
let [method, path, status, response] = parseArgs(args)
method = method.toLowerCase()
return rest[method as 'all'](`${import.meta.env.VITE_API_HOST}${path}`, (_req, res, ctx) => {
return res(ctx.status(status), ctx.json(response))
})
}),
)
beforeAll(() => void server.listen())
afterEach(() => void server.resetHandlers())
afterAll(() => void server.close())
async function waitForRequest (path: string): Promise<MockedRequest<DefaultBodyType>>
async function waitForRequest (path: string, flush: boolean): Promise<MockedRequest<DefaultBodyType>>
async function waitForRequest (method: HttpMethod, path: string): Promise<MockedRequest<DefaultBodyType>>
async function waitForRequest (method: HttpMethod, path: string, flush: boolean): Promise<MockedRequest<DefaultBodyType>>
async function waitForRequest (...args: [string] | [string, boolean] | [HttpMethod, string] | [HttpMethod, string, boolean]): Promise<MockedRequest<DefaultBodyType>> {
const [method, path, flush] = args.length === 1
? ['all', args[0]] // ['all', path]
: args.length === 2 && typeof args[1] === 'boolean'
? ['all', args[0], args[1]] // ['all', path, flush]
: args.length === 2
? [args[0], args[1]] // [method, path]
: args // [method, path, flush]
return waitForServerRequest(server, method, path, flush)
}
const originalUse = server.use.bind(server)
function use (...listeners: Listener[]) {
originalUse(
...listeners.map((args) => {
let [method, path, status, response] = parseArgs(args)
method = method.toLowerCase()
return rest[method as 'all'](`${import.meta.env.VITE_API_HOST}${path}`, (_req, res, ctx) => {
return res(ctx.status(status), ctx.json(response))
})
}),
)
}
return Object.assign(server, { waitForRequest, use })
}

View File

@ -1,27 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
interface WrapTestsProps <Item> {
task: string
list: Item[]
fn: (item: Item) => void
only?: boolean
testName?: (item: Item, index: number) => string
}
function wrapTests<Item> ({ task, list, fn, testName, only = false }: WrapTestsProps<Item>): void {
// @ts-ignore
const descFn = only ? context.only : context
descFn(task, () => {
for (const [index, item] of list.entries()) {
const name = testName !== undefined ? testName(item, index) : ''
// @ts-ignore
it(name, () => fn(item))
}
})
}
wrapTests.only = function <Item> ({ task, list, fn, testName }: WrapTestsProps<Item>): ReturnType<typeof wrapTests> {
wrapTests({ task, list, fn, testName, only: true })
}
export default wrapTests

View File

@ -1,5 +1,6 @@
import { isRef } from 'vue'
import useAsync from 'src/utils/use-async'
import { vi, expect, it, describe } from 'vitest'
describe('# Create async process', () => {
const someProcess = (): Promise<null> => Promise.resolve(null)
@ -7,39 +8,35 @@ describe('# Create async process', () => {
it('should expect active as Vue Ref type', () => {
const { active } = useAsync(someProcess)
expect(isRef(active)).to.be.true
expect(isRef(active)).toBe(true)
})
it('should correctly test active functionality', async () => {
const { active, run } = useAsync(someProcess)
expect(active.value).to.be.false
expect(active.value).toBe(false)
const promise = run()
expect(active.value).to.be.true
expect(active.value).toBe(true)
await promise
expect(active.value).to.be.false
expect(active.value).toBe(false)
})
it('should expect run as a function', () => {
const { run } = useAsync(someProcess)
expect(run).to.be.instanceof(Function)
expect(run).toBeInstanceOf(Function)
})
it('should expect original function called with correct params and return correct data', () => {
const someProcess = cy.stub().returns(Promise.resolve({ a: 1, b: null }))
it('should expect original function called with correct params and return correct data', async () => {
const someProcess = vi.fn().mockResolvedValue({ a: 1, b: null })
const { run } = useAsync(someProcess)
cy.wrap(run(null))
.its('a')
.should('to.be', 1)
.its('b')
.should('to.be', null)
expect(someProcess).to.be.calledWith(null)
const result = await run(null)
expect(result).toEqual({ a: 1, b: null })
expect(someProcess).toBeCalledWith(null)
})
})

1
src/vite-env.d.ts vendored
View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -5,6 +5,7 @@
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
/* Bundler mode */
"moduleResolution": "bundler",

View File

@ -1,3 +1,5 @@
/// <reference types="vitest" />
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
@ -14,4 +16,12 @@ export default defineConfig({
vue(),
analyzer({ summaryOnly: true }),
],
test: {
environment: 'happy-dom',
setupFiles: './src/setupTests.ts',
globals: true,
snapshotFormat: {
escapeString: false,
},
},
})