| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750 |
- import React from 'react'
- import { render, fireEvent, waitFor } from '@testing-library/react'
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
- import CartPage from '@/pages/cart/index'
- import { mockShowToast, mockShowModal, mockNavigateTo, mockSetStorageSync, mockRemoveStorageSync, mockGetStorageSync, mockRequest } from '~/__mocks__/taroMock'
- // Mock Taro API
- jest.mock('@tarojs/taro', () => jest.requireActual('~/__mocks__/taroMock'))
- // 使用真实CartContext,通过mock存储控制初始状态
- import { CartProvider } from '@/contexts/CartContext'
- // 购物车测试数据
- const mockCartItems = [
- {
- id: 1,
- parentGoodsId: 100, // 父商品ID
- name: '红色/M', // 子商品规格名称
- price: 29.9,
- image: 'test-image1.jpg',
- stock: 10,
- quantity: 2,
- },
- {
- id: 2,
- parentGoodsId: 200, // 父商品ID
- name: '蓝色/L', // 子商品规格名称
- price: 49.9,
- image: 'test-image2.jpg',
- stock: 2, // 改为2,触发库存不足提示(<=3)
- quantity: 1,
- },
- ]
- // mock数据
- const mockGoodsData = {
- 1: {
- id: 1,
- name: '红色/M', // 子商品规格名称
- price: 29.9,
- imageFile: { fullUrl: 'test-image1.jpg' },
- stock: 10,
- parent: { // 父商品信息
- id: 100,
- name: '测试商品1', // 父商品名称(不含规格)
- price: 29.9,
- costPrice: 20,
- stock: 50,
- imageFileId: 1,
- goodsType: 'normal',
- spuId: 0
- }
- },
- 2: {
- id: 2,
- name: '蓝色/L', // 子商品规格名称
- price: 49.9,
- imageFile: { fullUrl: 'test-image2.jpg' },
- stock: 2,
- parent: { // 父商品信息
- id: 200,
- name: '测试商品2', // 父商品名称(不含规格)
- price: 49.9,
- costPrice: 35,
- stock: 30,
- imageFileId: 2,
- goodsType: 'normal',
- spuId: 0
- }
- },
- 300: {
- id: 300,
- name: '单规格商品',
- price: 99.9,
- imageFile: { fullUrl: 'single.jpg' },
- stock: 10
- // 无parent字段,因为不是子商品
- }
- }
- // 使用getter延迟创建mockGoodsClient
- let mockGoodsClient
- jest.mock('@/api', () => {
- return {
- get goodsClient() {
- if (!mockGoodsClient) {
- // 第一次访问时创建mock
- mockGoodsClient = {
- ':id': {
- $get: jest.fn(({ param }: any) => {
- const goodsId = param?.id
- const idNum = Number(goodsId)
- const goodsData = mockGoodsData[idNum] || mockGoodsData[1]
- return Promise.resolve({
- status: 200,
- json: () => Promise.resolve(goodsData)
- })
- }),
- children: {
- $get: jest.fn()
- }
- }
- }
- }
- return mockGoodsClient
- }
- }
- })
- // Mock布局组件
- jest.mock('@/layouts/tab-bar-layout', () => ({
- TabBarLayout: ({ children }: any) => <div>{children}</div>,
- }))
- // Mock导航栏组件
- jest.mock('@/components/ui/navbar', () => ({
- Navbar: ({ title, onClickRight }: any) => (
- <div>
- <div>{title}</div>
- <button onClick={onClickRight}>清空购物车</button>
- </div>
- ),
- }))
- // Mock按钮组件
- jest.mock('@/components/ui/button', () => ({
- Button: ({ children, onClick, disabled, className }: any) => (
- <button onClick={onClick} className={className}>
- {children}
- </button>
- ),
- }))
- // Mock图片组件
- jest.mock('@/components/ui/image', () => ({
- Image: ({ src, className, mode }: any) => (
- <img src={src} className={className} alt="商品图片" />
- ),
- }))
- // 移除对useQueries的mock,使用真实hook
- // 创建测试用的QueryClient
- const createTestQueryClient = () => new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- staleTime: 0, // 立即过期,强制重新获取
- gcTime: 0, // 禁用垃圾回收
- enabled: true // 确保查询启用
- },
- mutations: { retry: false }
- }
- })
- // 包装组件提供QueryClientProvider和CartProvider
- const renderWithProviders = (ui: React.ReactElement) => {
- const testQueryClient = createTestQueryClient()
- return render(
- <QueryClientProvider client={testQueryClient}>
- <CartProvider>
- {ui}
- </CartProvider>
- </QueryClientProvider>
- )
- }
- // 导入api模块以触发mock初始化
- import * as api from '@/api'
- describe('购物车页面', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- // 设置默认购物车数据(包含2个商品)
- mockGetStorageSync.mockImplementation((key) => {
- if (key === 'mini_cart') {
- return { items: mockCartItems }
- }
- return null
- })
- mockShowModal.mockImplementation(() => Promise.resolve({ confirm: true }))
- // 触发goodsClient getter以确保mock被创建
- // 访问api.goodsClient会触发getter,创建mockGoodsClient
- if (api.goodsClient) {
- // mock已经被创建,jest.clearAllMocks()已经清除了调用记录
- }
- mockRequest.mockClear()
- })
- it('应该正确渲染购物车页面标题', () => {
- const { getByText } = renderWithProviders(<CartPage />)
- expect(getByText('购物车')).toBeDefined()
- })
- it('应该显示购物车中的商品列表', async () => {
- const { findByText } = renderWithProviders(<CartPage />)
- // 等待商品API被调用
- await waitFor(() => {
- expect(api.goodsClient[':id'].$get).toHaveBeenCalled()
- })
- // 等待查询完成,商品名称应该显示父商品名称
- expect(await findByText('测试商品1')).toBeDefined()
- expect(await findByText('测试商品2')).toBeDefined()
- expect(await findByText('¥29.90')).toBeDefined()
- expect(await findByText('¥49.90')).toBeDefined()
- })
- it('应该显示商品规格信息', async () => {
- const { findByText } = renderWithProviders(<CartPage />)
- expect(await findByText('红色/M')).toBeDefined()
- expect(await findByText('蓝色/L')).toBeDefined()
- })
- it('应该显示商品数量选择器', () => {
- const { getByDisplayValue } = renderWithProviders(<CartPage />)
- expect(getByDisplayValue('2')).toBeDefined() // 商品1的数量
- expect(getByDisplayValue('1')).toBeDefined() // 商品2的数量
- })
- it('应该显示底部结算栏', () => {
- const { getByText } = renderWithProviders(<CartPage />)
- expect(getByText('全选')).toBeDefined()
- expect(getByText('总计')).toBeDefined()
- expect(getByText('去结算(0)')).toBeDefined()
- })
- it('应该支持全选功能', () => {
- const { getByText } = renderWithProviders(<CartPage />)
- const selectAllButton = getByText('全选')
- fireEvent.click(selectAllButton)
- // 检查结算按钮文本变化
- expect(getByText('去结算(2)')).toBeDefined()
- })
- it('应该支持单个商品选择', () => {
- const { getByText } = renderWithProviders(<CartPage />)
- const selectAllButton = getByText('全选')
- fireEvent.click(selectAllButton)
- // 再次点击取消全选
- fireEvent.click(selectAllButton)
- expect(getByText('去结算(0)')).toBeDefined()
- })
- it('应该显示清空购物车按钮', () => {
- const { getByText } = renderWithProviders(<CartPage />)
- const clearButton = getByText('清空购物车')
- fireEvent.click(clearButton)
- expect(mockShowModal).toHaveBeenCalledWith({
- title: '清空购物车',
- content: '确定要清空购物车吗?',
- success: expect.any(Function),
- })
- })
- it('应该显示删除按钮', () => {
- const { getAllByText } = renderWithProviders(<CartPage />)
- const deleteButtons = getAllByText('删除')
- expect(deleteButtons).toHaveLength(2)
- fireEvent.click(deleteButtons[0])
- expect(mockShowModal).toHaveBeenCalledWith({
- title: '删除商品',
- content: '确定要删除这个商品吗?',
- success: expect.any(Function),
- })
- })
- it('应该显示库存不足提示', async () => {
- // 修复:库存提示显示逻辑
- // 商品2的购物车stock改为2,应该显示"仅剩2件"
- // 即使useQueries不返回数据,使用item.stock也会触发提示
- const { findByText } = renderWithProviders(<CartPage />)
- // 等待商品2加载完成
- await findByText('测试商品2')
- // 商品2的stock是2,应该显示"仅剩2件"
- // 使用findByText等待元素出现
- expect(await findByText('仅剩2件')).toBeDefined()
- })
- it('应该显示库存不足提示(API查询成功)', async () => {
- // 获取mock的goodsClient
- const api = require('@/api')
- // 使用spyOn确保我们监视正确的方法
- const goodsClientSpy = jest.spyOn(api.goodsClient[':id'], '$get')
- goodsClientSpy.mockReset()
- // 设置购物车数据,商品2的本地库存为5(不触发提示),API返回1(触发提示)
- const testCartItems = [
- {
- id: 1,
- parentGoodsId: 100,
- name: '测试商品1',
- price: 29.9,
- image: 'test-image1.jpg',
- stock: 10,
- quantity: 2,
- },
- {
- id: 2,
- parentGoodsId: 200,
- name: '测试商品2',
- price: 49.9,
- image: 'test-image2.jpg',
- stock: 5, // 本地库存5,不触发提示(>3)
- quantity: 1,
- },
- ]
- mockGetStorageSync.mockReturnValue({ items: testCartItems })
- // 设置mock返回正确的数据
- goodsClientSpy.mockImplementation(({ param }: any) => {
- const goodsId = param?.id
- // 根据商品ID返回不同的库存数据
- if (goodsId === 1) {
- return Promise.resolve({
- status: 200,
- json: () => Promise.resolve({
- id: 1,
- name: '测试商品1',
- price: 29.9,
- imageFile: { fullUrl: 'test-image1.jpg' },
- stock: 10
- })
- })
- } else if (goodsId === 2) {
- return Promise.resolve({
- status: 200,
- json: () => Promise.resolve({
- id: 2,
- name: '测试商品2',
- price: 49.9,
- imageFile: { fullUrl: 'test-image2.jpg' },
- stock: 1 // 低库存,触发库存提示
- })
- })
- }
- // 默认返回商品1的数据
- return Promise.resolve({
- status: 200,
- json: () => Promise.resolve({
- id: 1,
- name: '测试商品1',
- price: 29.9,
- imageFile: { fullUrl: 'test-image1.jpg' },
- stock: 10
- })
- })
- })
- const { findByText } = renderWithProviders(<CartPage />)
- // 等待商品2加载完成
- await findByText('测试商品2')
- // 等待查询完成
- await new Promise(resolve => setTimeout(resolve, 200))
- // 商品2的API库存是1,应该显示"仅剩1件"
- expect(await findByText('仅剩1件')).toBeDefined()
- // 清理spy
- goodsClientSpy.mockRestore()
- })
- it('应该显示广告区域', () => {
- const { container } = renderWithProviders(<CartPage />)
- const adElement = container.querySelector('.cart-advertisement')
- expect(adElement).toBeDefined()
- })
- describe('空购物车状态', () => {
- beforeEach(() => {
- // 设置空购物车数据
- mockGetStorageSync.mockReturnValue({ items: [] })
- // 确保其他mock被清除
- mockShowModal.mockImplementation(() => Promise.resolve({ confirm: true }))
- api.goodsClient[':id'].$get.mockClear()
- mockRequest.mockClear()
- })
- it('应该显示空购物车状态', async () => {
- const { findByText } = renderWithProviders(<CartPage />)
- expect(await findByText('购物车是空的')).toBeDefined()
- expect(await findByText('去首页逛逛')).toBeDefined()
- })
- it('应该隐藏底部结算栏', async () => {
- const { queryByText, findByText } = renderWithProviders(<CartPage />)
- // 等待空状态显示,确保骨架屏已消失
- await findByText('购物车是空的')
- expect(queryByText('去结算')).toBeNull()
- })
- })
- describe('结算功能', () => {
- it('应该阻止未选择商品时结算', () => {
- const { getByText } = renderWithProviders(<CartPage />)
- const checkoutButton = getByText('去结算(0)')
- fireEvent.click(checkoutButton)
- expect(mockShowToast).toHaveBeenCalledWith({
- title: '请选择商品',
- icon: 'none',
- })
- })
- it('应该允许选择商品后结算', () => {
- const { getByText } = renderWithProviders(<CartPage />)
- const selectAllButton = getByText('全选')
- const checkoutButton = getByText('去结算(0)')
- fireEvent.click(selectAllButton)
- fireEvent.click(checkoutButton)
- expect(mockSetStorageSync).toHaveBeenCalledWith('checkoutItems', {
- items: expect.any(Array),
- totalAmount: expect.any(Number),
- })
- expect(mockNavigateTo).toHaveBeenCalledWith({
- url: '/pages/order-submit/index',
- })
- })
- })
- describe('规格切换功能', () => {
- beforeEach(() => {
- // 确保规格选择器API mock被清除
- mockRequest.mockClear()
- })
- it('应该显示规格选择区域', async () => {
- const { findByText } = renderWithProviders(<CartPage />)
- // 检查规格文本是否显示
- expect(await findByText('红色/M')).toBeDefined()
- expect(await findByText('蓝色/L')).toBeDefined()
- })
- it('规格区域应该可点击并打开规格选择器', async () => {
- const { findByText, container } = renderWithProviders(<CartPage />)
- // 获取规格元素
- const specElement = await findByText('红色/M')
- // 验证元素存在
- expect(specElement).toBeDefined()
- // 点击规格区域 - 点击规格文本的父元素(div.goods-specs)
- const specContainer = container.querySelector('.goods-specs')
- fireEvent.click(specContainer || specElement)
- // 验证规格选择器应该显示(通过检查规格选择器组件是否被渲染)
- // 由于GoodsSpecSelector组件是真实组件,我们需要检查其props
- // 规格选择器标题"选择规格"应该显示
- await waitFor(() => {
- expect(container.querySelector('.spec-modal-title')).toBeDefined()
- })
- })
- it('应该加载子商品数据并显示规格选择器', async () => {
- // Mock子商品API调用
- const mockChildGoodsResponse = {
- status: 200,
- json: () => Promise.resolve({
- data: [
- {
- id: 101, // 新子商品ID
- name: '测试商品1 - 蓝色/S',
- price: 29.9,
- stock: 8,
- imageFile: { fullUrl: 'test-image1-blue.jpg' }
- },
- {
- id: 102, // 另一个子商品ID
- name: '测试商品1 - 黑色/M',
- price: 34.9,
- stock: 5,
- imageFile: { fullUrl: 'test-image1-black.jpg' }
- }
- ],
- total: 2,
- page: 1,
- pageSize: 100
- })
- }
- // Mock goodsClient的children API - 使用导入的api模块
- const childrenSpy = jest.spyOn(api.goodsClient[':id'].children, '$get')
- childrenSpy.mockImplementation(({ param, query }: any) => {
- return Promise.resolve(mockChildGoodsResponse)
- })
- const { findByText, container } = renderWithProviders(<CartPage />)
- // 首先等待商品API被调用,确保商品数据加载
- await waitFor(() => {
- expect(api.goodsClient[':id'].$get).toHaveBeenCalled()
- })
- // 等待商品名称显示
- await findByText(/测试商品1/)
- // 点击规格区域打开选择器 - 规格区域显示的是规格名称"红色/M"
- const specElement = await findByText('红色/M')
- fireEvent.click(specElement)
- // 等待API调用
- await waitFor(() => {
- expect(childrenSpy).toHaveBeenCalled()
- })
- // 清理spy
- childrenSpy.mockRestore()
- })
- it('应该支持切换规格并更新商品信息', async () => {
- // Mock子商品API调用
- const mockChildGoodsResponse = {
- status: 200,
- json: () => Promise.resolve({
- data: [
- {
- id: 101, // 新子商品ID
- name: '测试商品1 - 蓝色/S',
- price: 29.9,
- stock: 8,
- imageFile: { fullUrl: 'test-image1-blue.jpg' }
- },
- {
- id: 1, // 当前子商品ID
- name: '测试商品1 - 红色/M',
- price: 29.9,
- stock: 10,
- imageFile: { fullUrl: 'test-image1.jpg' }
- }
- ],
- total: 2,
- page: 1,
- pageSize: 100
- })
- }
- // Mock goodsClient的children API
- const api = require('@/api')
- const childrenSpy = jest.spyOn(api.goodsClient[':id'].children, '$get')
- childrenSpy.mockImplementation(({ param, query }: any) => {
- return Promise.resolve(mockChildGoodsResponse)
- })
- // Mock switchSpec调用
- const { getByText, container } = renderWithProviders(<CartPage />)
- // 点击规格区域打开选择器 - 点击规格文本的父元素(div.goods-specs)
- const specElement = getByText('红色/M')
- const specContainer = container.querySelector('.goods-specs')
- fireEvent.click(specContainer || specElement)
- // 等待API调用
- await waitFor(() => {
- expect(childrenSpy).toHaveBeenCalled()
- })
- // 注意:由于GoodsSpecSelector是真实组件,在测试环境中无法直接模拟其内部状态
- // 这里我们验证API调用和基本交互
- // 清理spy
- childrenSpy.mockRestore()
- })
- it('切换规格后应该更新购物车总价', async () => {
- const { getByText, getAllByText } = renderWithProviders(<CartPage />)
- // 先全选商品
- const selectAllButton = getByText('全选')
- fireEvent.click(selectAllButton)
- // 获取切换前的总价 - 使用更具体的查询
- const totalAmountElements = getAllByText(/¥\d+\.\d{2}/)
- // 最后一个元素应该是总计金额
- const totalAmountElement = totalAmountElements[totalAmountElements.length - 1]
- const totalAmountBefore = totalAmountElement.textContent
- // 验证总价显示存在
- expect(getByText(/总计/)).toBeDefined()
- expect(totalAmountBefore).toMatch(/¥\d+\.\d{2}/)
- })
- it('库存不足的规格应该被禁用或提示', async () => {
- // Mock子商品API调用,包含库存不足的商品
- const mockChildGoodsResponse = {
- status: 200,
- json: () => Promise.resolve({
- data: [
- {
- id: 103,
- name: '测试商品1 - 白色/XL',
- price: 39.9,
- stock: 0, // 库存为0
- imageFile: { fullUrl: 'test-image1-white.jpg' }
- },
- {
- id: 104,
- name: '测试商品1 - 黄色/L',
- price: 32.9,
- stock: 1, // 低库存
- imageFile: { fullUrl: 'test-image1-yellow.jpg' }
- }
- ],
- total: 2,
- page: 1,
- pageSize: 100
- })
- }
- const api = require('@/api')
- const childrenSpy = jest.spyOn(api.goodsClient[':id'].children, '$get')
- childrenSpy.mockImplementation(({ param, query }: any) => {
- return Promise.resolve(mockChildGoodsResponse)
- })
- const { getByText, container } = renderWithProviders(<CartPage />)
- // 点击规格区域 - 点击规格文本的父元素(div.goods-specs)
- const specElement = getByText('红色/M')
- const specContainer = container.querySelector('.goods-specs')
- fireEvent.click(specContainer || specElement)
- // 验证API被调用
- await waitFor(() => {
- expect(childrenSpy).toHaveBeenCalled()
- })
- childrenSpy.mockRestore()
- })
- it('应该处理API返回404错误(父商品不存在)', async () => {
- // Mock API返回404错误
- const mockErrorResponse = {
- status: 404,
- json: () => Promise.resolve({
- code: 404,
- message: '父商品不存在或不是有效的父商品'
- })
- }
- const api = require('@/api')
- const childrenSpy = jest.spyOn(api.goodsClient[':id'].children, '$get')
- childrenSpy.mockImplementation(({ param, query }: any) => {
- return Promise.resolve(mockErrorResponse)
- })
- const { getByText, findByText, container } = renderWithProviders(<CartPage />)
- // 点击规格区域打开选择器 - 点击规格文本的父元素(div.goods-specs)
- const specElement = getByText('红色/M')
- // 查找父元素div.goods-specs
- const specContainer = container.querySelector('.goods-specs')
- fireEvent.click(specContainer || specElement)
- // 等待规格选择器显示 - 精确匹配标题
- await waitFor(() => {
- expect(getByText(/^选择规格$/)).toBeDefined()
- })
- // 验证API被调用
- await waitFor(() => {
- expect(childrenSpy).toHaveBeenCalled()
- })
- // 等待错误消息显示 - GoodsSpecSelector会显示API返回的错误信息
- await waitFor(() => {
- expect(getByText('父商品不存在或不是有效的父商品')).toBeDefined()
- })
- childrenSpy.mockRestore()
- })
- it('单规格商品不应该显示规格切换区域', () => {
- // 设置购物车数据,包含单规格商品
- const singleSpecCartItems = [
- {
- id: 300,
- parentGoodsId: 0, // 单规格商品
- name: '单规格商品',
- price: 99.9,
- image: 'single.jpg',
- stock: 10,
- quantity: 1,
- // 没有spec字段
- }
- ]
- mockGetStorageSync.mockReturnValue({ items: singleSpecCartItems })
- // Mock goodsClient 返回单规格商品数据(无parent对象)
- api.goodsClient[':id'].$get.mockImplementation(({ param }: any) => {
- const goodsId = param?.id
- if (goodsId === 300) {
- const singleSpecGoodsData = {
- id: 300,
- name: '单规格商品',
- price: 99.9,
- imageFile: { fullUrl: 'single.jpg' },
- stock: 10
- // 无parent字段,因为不是子商品
- }
- return Promise.resolve({
- status: 200,
- json: () => Promise.resolve(singleSpecGoodsData)
- })
- }
- // 默认返回mockGoodsData[1]
- const goodsData = mockGoodsData[1]
- return Promise.resolve({
- status: 200,
- json: () => Promise.resolve(goodsData)
- })
- })
- const { queryByText, getByText, container } = renderWithProviders(<CartPage />)
- // 商品名称应该显示
- expect(getByText('单规格商品')).toBeDefined()
- // 不应该显示"选择规格"文本
- expect(queryByText('选择规格')).toBeNull()
- // 不应该显示规格文本(如"红色/M")
- const specElements = Array.from(container.querySelectorAll('.goods-specs'))
- expect(specElements.length).toBe(0)
- })
- })
- })
|