全面单元测试生成

325 浏览
28 试用
5 购买
Nov 24, 2025更新

根据提供的代码片段与测试场景生成高质量单元测试用例,涵盖正向、反向、边界条件及依赖项模拟,支持常见测试框架及语言,可直接集成CI/CD流水线,提升测试覆盖率与维护效率,降低手动编写与回归成本。

下面提供一组使用 pytest 与 pytest-mock 的单元测试示例,覆盖正向、边界和反向场景,并对依赖进行模拟与交互断言。请将 your_module 替换为实际模块名。

文件名示例:tests/test_user_service.py

import pytest from datetime import datetime from unittest.mock import call

替换为实际的模块路径

from your_module import ( UserService, User, EmailValidator, UserRepository, Clock, Mailer, )

@pytest.fixture def fixed_now(): return datetime(2024, 1, 2, 3, 4, 5)

@pytest.fixture def make_service(mocker, fixed_now): def _make(email_valid=True, exists=False, save_id="u_123"): repo = mocker.create_autospec(UserRepository, instance=True) email_validator = mocker.create_autospec(EmailValidator, instance=True) clock = mocker.create_autospec(Clock, instance=True) mailer = mocker.create_autospec(Mailer, instance=True)

    email_validator.is_valid.return_value = email_valid
    repo.exists_username.return_value = exists
    repo.save.return_value = save_id
    clock.now.return_value = fixed_now

    service = UserService(repo, email_validator, clock, mailer)
    return service, repo, email_validator, clock, mailer
return _make

def test_register_user_success_order_and_fields(mocker, fixed_now, make_service, capsys): service, repo, email_validator, clock, mailer = make_service()

# 断言调用顺序:exists_username -> save -> send_welcome
def save_side_effect(user):
    assert repo.exists_username.call_count == 1
    return "u_123"

repo.save.side_effect = save_side_effect

def send_welcome_side_effect(email, username):
    # send_welcome 调用前应已 save 一次
    assert repo.save.call_count == 1
    saved_user = repo.save.call_args[0][0]
    assert saved_user.username == username
    assert saved_user.email == email

mailer.send_welcome.side_effect = send_welcome_side_effect

result = service.register_user(
    username="john_doe",
    email="john@example.com",
    password="Abc12345",
    referral_code="ABC123"
)

assert result == {
    "id": "u_123",
    "username": "john_doe",
    "created_at": fixed_now.isoformat() + "Z",
    "referral_code": "ABC123",
}

email_validator.is_valid.assert_called_once_with("john@example.com")
repo.exists_username.assert_called_once_with("john_doe")
repo.save.assert_called_once()

# 校验保存的 User 对象
saved_user = repo.save.call_args[0][0]
assert isinstance(saved_user, User)
assert saved_user.id is None
assert saved_user.username == "john_doe"
assert saved_user.email == "john@example.com"
assert saved_user.created_at == fixed_now
assert saved_user.referral_code == "ABC123"

mailer.send_welcome.assert_called_once_with("john@example.com", "john_doe")

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

@pytest.mark.parametrize("username", ["abc", "a" * 20]) @pytest.mark.parametrize("referral", ["ABCDEF", "A" * 12]) def test_register_user_boundary_username_and_referral(mocker, fixed_now, make_service, username, referral, capsys): service, repo, email_validator, clock, mailer = make_service()

result = service.register_user(
    username=username,
    email="u@example.com",
    password="Abc12345",  # 正好8,包含字母和数字
    referral_code=referral
)

assert result["id"] == "u_123"
assert result["username"] == username
assert result["created_at"] == fixed_now.isoformat() + "Z"
assert result["referral_code"] == referral

email_validator.is_valid.assert_called_once_with("u@example.com")
repo.exists_username.assert_called_once_with(username)
repo.save.assert_called_once()
mailer.send_welcome.assert_called_once_with("u@example.com", username)

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

def test_register_user_success_no_referral(mocker, fixed_now, make_service, capsys): service, repo, email_validator, clock, mailer = make_service()

result = service.register_user(
    username="user_123",
    email="ok@example.com",
    password="Abc12345",
    referral_code=None
)

assert result == {
    "id": "u_123",
    "username": "user_123",
    "created_at": fixed_now.isoformat() + "Z",
    "referral_code": None,
}

email_validator.is_valid.assert_called_once_with("ok@example.com")
repo.exists_username.assert_called_once_with("user_123")
repo.save.assert_called_once()
mailer.send_welcome.assert_called_once_with("ok@example.com", "user_123")

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

def test_register_user_invalid_email_short_circuit(mocker, make_service, capsys): service, repo, email_validator, clock, mailer = make_service(email_valid=False)

with pytest.raises(ValueError, match="invalid_email"):
    service.register_user(username="john_doe", email="bad@", password="Abc12345", referral_code="ABC123")

email_validator.is_valid.assert_called_once_with("bad@")
repo.exists_username.assert_not_called()
repo.save.assert_not_called()
mailer.send_welcome.assert_not_called()

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

def test_register_user_weak_password_too_short_short_circuit(mocker, make_service, capsys): service, repo, email_validator, clock, mailer = make_service(email_valid=True)

with pytest.raises(ValueError, match="weak_password: length < 8"):
    service.register_user(username="john_doe", email="john@example.com", password="Abc123", referral_code="ABC123")

email_validator.is_valid.assert_called_once_with("john@example.com")
repo.exists_username.assert_not_called()
repo.save.assert_not_called()
mailer.send_welcome.assert_not_called()

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

@pytest.mark.parametrize("pwd", ["abcdefgh", "12345678"]) def test_register_user_weak_password_missing_letters_or_digits_short_circuit(mocker, make_service, pwd, capsys): service, repo, email_validator, clock, mailer = make_service(email_valid=True)

with pytest.raises(ValueError, match="weak_password: must contain letters and digits"):
    service.register_user(username="john_doe", email="john@example.com", password=pwd, referral_code="ABC123")

email_validator.is_valid.assert_called_once_with("john@example.com")
repo.exists_username.assert_not_called()
repo.save.assert_not_called()
mailer.send_welcome.assert_not_called()

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

@pytest.mark.parametrize("bad_referral", ["ABCDE", "abc!", "A" * 13]) def test_register_user_invalid_referral_short_circuit(mocker, make_service, bad_referral, capsys): service, repo, email_validator, clock, mailer = make_service(email_valid=True)

with pytest.raises(ValueError, match="invalid_referral_code"):
    service.register_user(username="john_doe", email="john@example.com", password="Abc12345", referral_code=bad_referral)

email_validator.is_valid.assert_called_once_with("john@example.com")
repo.exists_username.assert_not_called()
repo.save.assert_not_called()
mailer.send_welcome.assert_not_called()

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

def test_register_user_username_taken(mocker, make_service, capsys): service, repo, email_validator, clock, mailer = make_service(email_valid=True, exists=True)

with pytest.raises(ValueError, match="username_taken"):
    service.register_user(username="john_doe", email="john@example.com", password="Abc12345", referral_code="ABC123")

email_validator.is_valid.assert_called_once_with("john@example.com")
repo.exists_username.assert_called_once_with("john_doe")
repo.save.assert_not_called()
mailer.send_welcome.assert_not_called()

captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""

说明:

  • 使用 pytest 和 pytest-mock 的 autospec 来严格模拟依赖,避免接口漂移。
  • 正向用例断言:保存对象内容、返回值、时间戳格式(包含 Z)、依赖调用次数与参数,并用 side_effect 断言调用顺序:exists_username -> save -> send_welcome。
  • 边界用例使用 parametrize 覆盖用户名长度最小/最大、推荐码最小/最大、密码恰好8位且包含字母数字。
  • 反向用例分别覆盖邮箱校验失败、密码过短、密码缺少字母或数字、非法推荐码、重复用户名,断言抛出特定错误消息且依赖不被调用,实现短路行为验证。
  • 使用 capsys 校验无额外输出,便于在 CI 并行执行和覆盖率统计。

下面是使用 JUnit 5 + Mockito 编写的全面单元测试示例,覆盖正向、边界与反向用例,并对依赖进行模拟与调用顺序验证。将该测试类放置在与被测类相同的包下或测试源路径中即可运行。

import static org.junit.jupiter.api.Assertions.; import static org.mockito.Mockito.;

import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; import java.util.List;

import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(MockitoExtension.class) public class CheckoutServiceTest {

@Mock
InventoryGateway inventory;

@Mock
PaymentGateway payment;

@Mock
DiscountPolicy discountPolicy;

CheckoutService sut;

@BeforeEach
void setUp() {
    sut = new CheckoutService(inventory, payment, discountPolicy);
}

private LineItem item(String sku, int qty, String unitPrice) {
    return new LineItem(sku, qty, unitPrice == null ? null : new BigDecimal(unitPrice));
}

// 正向:STANDARD 无优惠券,含四舍五入校验,token校验
@Test
void checkout_standard_noCoupon_rounding_and_token() {
    List<LineItem> items = Arrays.asList(
            item("A", 2, "10.00"),   // 20.00
            item("B", 1, "5.125")    // 5.125
    );
    Order order = new Order(items, null, Membership.STANDARD);

    // subtotal raw = 25.125 -> round(2) HALF_UP -> 25.13
    when(discountPolicy.discountFor(eq(Membership.STANDARD), eq(new BigDecimal("25.13")), isNull()))
            .thenReturn(BigDecimal.ZERO);

    when(inventory.reserve("A", 2)).thenReturn(true);
    when(inventory.reserve("B", 1)).thenReturn(true);
    when(payment.preAuth(new BigDecimal("25.13"))).thenReturn("tok_abc");

    Receipt r = sut.checkout(order);

    assertEquals(new BigDecimal("25.13"), r.getSubtotal(), "subtotal should be rounded to cents");
    assertEquals(BigDecimal.ZERO, r.getDiscount(), "discount should be zero");
    assertEquals(new BigDecimal("25.13"), r.getTotal(), "total should equal subtotal when discount is zero");
    assertEquals("tok_abc", r.getPaymentToken());

    // 验证调用顺序:先库存再支付
    InOrder inorder = inOrder(inventory, payment);
    inorder.verify(inventory).reserve("A", 2);
    inorder.verify(inventory).reserve("B", 1);
    inorder.verify(payment).preAuth(new BigDecimal("25.13"));

    // 验证依赖调用次数与参数
    verify(discountPolicy, times(1))
            .discountFor(eq(Membership.STANDARD), eq(new BigDecimal("25.13")), isNull());
    verify(inventory, times(1)).reserve("A", 2);
    verify(inventory, times(1)).reserve("B", 1);
    verify(payment, times(1)).preAuth(new BigDecimal("25.13"));
}

// 正向:GOLD + coupon,折扣值带小数,校验 total 的四舍五入
@Test
void checkout_gold_with_coupon_discount_and_total_rounding() {
    List<LineItem> items = Arrays.asList(
            item("X", 3, "4.999"),   // 14.997
            item("Y", 2, "5.066")    // 10.132 -> subtotal raw: 25.129 -> round -> 25.13
    );
    Order order = new Order(items, "SAVE5", Membership.GOLD);

    BigDecimal expectedSubtotal = new BigDecimal("25.13");
    BigDecimal discount = new BigDecimal("5.432"); // arbitrary discount with extra precision

    when(discountPolicy.discountFor(eq(Membership.GOLD), eq(expectedSubtotal), eq("SAVE5")))
            .thenReturn(discount);

    when(inventory.reserve("X", 3)).thenReturn(true);
    when(inventory.reserve("Y", 2)).thenReturn(true);

    // total = 25.13 - 5.432 = 19.698 -> round(2) = 19.70
    BigDecimal expectedTotal = new BigDecimal("19.70");
    when(payment.preAuth(expectedTotal)).thenReturn("tok_abc");

    Receipt r = sut.checkout(order);

    assertEquals(expectedSubtotal, r.getSubtotal());
    assertEquals(discount, r.getDiscount());
    assertEquals(expectedTotal, r.getTotal());
    assertEquals("tok_abc", r.getPaymentToken());

    InOrder inorder = inOrder(inventory, payment);
    inorder.verify(inventory).reserve("X", 3);
    inorder.verify(inventory).reserve("Y", 2);
    inorder.verify(payment).preAuth(expectedTotal);

    verify(discountPolicy).discountFor(eq(Membership.GOLD), eq(expectedSubtotal), eq("SAVE5"));
}

// 正向:PLATINUM + coupon,折扣等于subtotal -> total 为 0.00
@Test
void checkout_platinum_max_discount_total_zero() {
    List<LineItem> items = Arrays.asList(
            item("P", 1, "100.00")
    );
    Order order = new Order(items, "MAX", Membership.PLATINUM);

    BigDecimal expectedSubtotal = new BigDecimal("100.00");
    when(discountPolicy.discountFor(eq(Membership.PLATINUM), eq(expectedSubtotal), eq("MAX")))
            .thenReturn(new BigDecimal("100.00"));

    when(inventory.reserve("P", 1)).thenReturn(true);
    when(payment.preAuth(new BigDecimal("0.00"))).thenReturn("tok_abc");

    Receipt r = sut.checkout(order);

    assertEquals(expectedSubtotal, r.getSubtotal());
    assertEquals(new BigDecimal("100.00"), r.getDiscount());
    assertEquals(new BigDecimal("0.00"), r.getTotal());
    assertEquals("tok_abc", r.getPaymentToken());

    InOrder inorder = inOrder(inventory, payment);
    inorder.verify(inventory).reserve("P", 1);
    inorder.verify(payment).preAuth(new BigDecimal("0.00"));
}

// 边界:空订单
@Test
void empty_order_throws() {
    Order order = new Order(Collections.emptyList(), null, Membership.STANDARD);
    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> sut.checkout(order));
    assertEquals("empty_order", ex.getMessage());
    verifyNoInteractions(inventory, payment, discountPolicy);
}

// 边界:数量为 0
@Test
void invalid_qty_throws_and_message_contains_sku() {
    Order order = new Order(Arrays.asList(item("BADQTY", 0, "1.00")), null, Membership.STANDARD);
    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> sut.checkout(order));
    assertEquals("invalid_qty:BADQTY", ex.getMessage());
    verifyNoInteractions(inventory, payment, discountPolicy);
}

// 边界:价格为 null
@Test
void invalid_price_null_throws_and_message_contains_sku() {
    Order order = new Order(Arrays.asList(item("NULLPRICE", 1, null)), null, Membership.STANDARD);
    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> sut.checkout(order));
    assertEquals("invalid_price:NULLPRICE", ex.getMessage());
    verifyNoInteractions(inventory, payment, discountPolicy);
}

// 边界:价格为负
@Test
void invalid_price_negative_throws_and_message_contains_sku() {
    Order order = new Order(Arrays.asList(item("NEGPRICE", 1, "-0.01")), null, Membership.STANDARD);
    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> sut.checkout(order));
    assertEquals("invalid_price:NEGPRICE", ex.getMessage());
    verifyNoInteractions(inventory, payment, discountPolicy);
}

// 边界:单件商品金额精度 0.005 -> subtotal 四舍五入到 0.01
@Test
void precision_boundary_0_005_rounds_up_to_cent() {
    Order order = new Order(Arrays.asList(item("CENTS", 1, "0.005")), null, Membership.STANDARD);
    when(discountPolicy.discountFor(eq(Membership.STANDARD), eq(new BigDecimal("0.01")), isNull()))
            .thenReturn(BigDecimal.ZERO);
    when(inventory.reserve("CENTS", 1)).thenReturn(true);
    when(payment.preAuth(new BigDecimal("0.01"))).thenReturn("tok_abc");

    Receipt r = sut.checkout(order);

    assertEquals(new BigDecimal("0.01"), r.getSubtotal());
    assertEquals(BigDecimal.ZERO, r.getDiscount());
    assertEquals(new BigDecimal("0.01"), r.getTotal());
    assertEquals("tok_abc", r.getPaymentToken());
}

// 边界:折扣为负 -> 截断为 0
@Test
void negative_discount_truncated_to_zero() {
    Order order = new Order(Arrays.asList(item("D1", 2, "5.00")), null, Membership.STANDARD); // subtotal 10.00
    when(discountPolicy.discountFor(eq(Membership.STANDARD), eq(new BigDecimal("10.00")), isNull()))
            .thenReturn(new BigDecimal("-3.00"));
    when(inventory.reserve("D1", 2)).thenReturn(true);
    when(payment.preAuth(new BigDecimal("10.00"))).thenReturn("tok_abc");

    Receipt r = sut.checkout(order);

    assertEquals(new BigDecimal("10.00"), r.getSubtotal());
    assertEquals(BigDecimal.ZERO, r.getDiscount(), "negative discount should be coerced to zero");
    assertEquals(new BigDecimal("10.00"), r.getTotal());
}

// 边界:折扣超额 -> 截断为 subtotal
@Test
void discount_exceeds_subtotal_clipped() {
    Order order = new Order(Arrays.asList(item("D2", 1, "10.00")), null, Membership.GOLD);
    when(discountPolicy.discountFor(eq(Membership.GOLD), eq(new BigDecimal("10.00")), isNull()))
            .thenReturn(new BigDecimal("15.00")); // exceeds subtotal
    when(inventory.reserve("D2", 1)).thenReturn(true);
    when(payment.preAuth(new BigDecimal("0.00"))).thenReturn("tok_abc");

    Receipt r = sut.checkout(order);

    assertEquals(new BigDecimal("10.00"), r.getSubtotal());
    assertEquals(new BigDecimal("10.00"), r.getDiscount(), "discount should be clipped to subtotal");
    assertEquals(new BigDecimal("0.00"), r.getTotal());
}

// 边界:大数量聚合
@Test
void large_quantity_aggregation() {
    Order order = new Order(Arrays.asList(item("BULK", 1000, "0.01")), "BULK10", Membership.STANDARD); // subtotal 10.00
    when(discountPolicy.discountFor(eq(Membership.STANDARD), eq(new BigDecimal("10.00")), eq("BULK10")))
            .thenReturn(BigDecimal.ZERO);
    when(inventory.reserve("BULK", 1000)).thenReturn(true);
    when(payment.preAuth(new BigDecimal("10.00"))).thenReturn("tok_abc");

    Receipt r = sut.checkout(order);

    assertEquals(new BigDecimal("10.00"), r.getSubtotal());
    assertEquals(BigDecimal.ZERO, r.getDiscount());
    assertEquals(new BigDecimal("10.00"), r.getTotal());
    assertEquals("tok_abc", r.getPaymentToken());

    verify(inventory).reserve("BULK", 1000);
    verify(payment).preAuth(new BigDecimal("10.00"));
}

// 反向:库存保留返回 false -> OutOfStockException,且不调用支付
@Test
void inventory_reserve_false_should_throw_and_not_call_payment() {
    List<LineItem> items = Arrays.asList(item("I1", 1, "3.00"), item("I2", 2, "4.00")); // subtotal 11.00
    Order order = new Order(items, null, Membership.STANDARD);

    when(discountPolicy.discountFor(eq(Membership.STANDARD), eq(new BigDecimal("11.00")), isNull()))
            .thenReturn(BigDecimal.ZERO);

    when(inventory.reserve("I1", 1)).thenReturn(true);
    when(inventory.reserve("I2", 2)).thenReturn(false); // triggers reserve_failed

    OutOfStockException ex = assertThrows(OutOfStockException.class, () -> sut.checkout(order));
    assertEquals("reserve_failed:I2", ex.getMessage());

    verify(payment, times(0)).preAuth(any());
    // 验证库存调用发生
    verify(inventory).reserve("I1", 1);
    verify(inventory).reserve("I2", 2);
}

// 反向:库存抛出异常 -> 冒泡,且不调用支付
@Test
void inventory_reserve_throws_should_bubble_and_not_call_payment() {
    List<LineItem> items = Arrays.asList(item("J1", 1, "1.00"), item("J2", 1, "2.00")); // subtotal 3.00
    Order order = new Order(items, null, Membership.STANDARD);

    when(discountPolicy.discountFor(eq(Membership.STANDARD), eq(new BigDecimal("3.00")), isNull()))
            .thenReturn(BigDecimal.ZERO);

    when(inventory.reserve("J1", 1)).thenReturn(true);
    when(inventory.reserve("J2", 1)).thenThrow(new OutOfStockException("no_stock"));

    OutOfStockException ex = assertThrows(OutOfStockException.class, () -> sut.checkout(order));
    assertEquals("no_stock", ex.getMessage());

    verify(payment, never()).preAuth(any());
    verify(inventory).reserve("J1", 1);
    verify(inventory).reserve("J2", 1);
}

// 反向:支付异常 -> 库存调用不受影响,异常冒泡
@Test
void payment_exception_propagates_and_inventory_calls_remain() {
    List<LineItem> items = Arrays.asList(item("K1", 2, "2.50")); // subtotal 5.00
    Order order = new Order(items, "C", Membership.GOLD);

    when(discountPolicy.discountFor(eq(Membership.GOLD), eq(new BigDecimal("5.00")), eq("C")))
            .thenReturn(new BigDecimal("1.25"));
    when(inventory.reserve("K1", 2)).thenReturn(true);

    BigDecimal expectedTotal = new BigDecimal("3.75"); // 5.00 - 1.25
    when(payment.preAuth(expectedTotal)).thenThrow(new PaymentException("fail"));

    PaymentException ex = assertThrows(PaymentException.class, () -> sut.checkout(order));
    assertEquals("fail", ex.getMessage());

    // 库存已调用,支付被调用一次但抛异常
    verify(inventory).reserve("K1", 2);
    verify(payment).preAuth(expectedTotal);

    // 调用顺序验证
    InOrder inorder = inOrder(inventory, payment);
    inorder.verify(inventory).reserve("K1", 2);
    inorder.verify(payment).preAuth(expectedTotal);
}

}

下面给出可直接用于CI的 Jest 单元测试示例,覆盖所要求的正向/反向/重试/超时/缓存/forceRefresh 等场景。请将 require 路径替换为你项目中 DataFetcher 的实际路径。

文件名示例:data-fetcher.test.js

/* eslint-env jest */

const { DataFetcher } = require('./DataFetcher'); // 替换为实际路径

// 简易 AbortController 兜底(保证在老 Node 或 jsdom 环境也能跑)
if (typeof global.AbortController === 'undefined') {
  class FakeSignal {
    constructor() { this.aborted = false; this._listeners = []; }
    addEventListener(evt, fn) {
      if (evt === 'abort') this._listeners.push(fn);
    }
  }
  class FakeAbortController {
    constructor() { this.signal = new FakeSignal(); }
    abort() {
      this.signal.aborted = true;
      this.signal._listeners.forEach(fn => {
        try { fn(); } catch (_) {}
      });
    }
  }
  global.AbortController = FakeAbortController;
}

describe('DataFetcher getJson', () => {
  let http;
  let cache;
  let df;
  let store; // 模拟内存缓存

  beforeEach(() => {
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
    store = null;
    cache = {
      get: jest.fn((key) => store),
      set: jest.fn((key, value) => { store = value; }),
    };
    http = {
      fetch: jest.fn(),
    };
    df = new DataFetcher(http, cache); // 使用默认 now=Date.now(受 fake timers 控制)
  });

  afterEach(() => {
    jest.useRealTimers();
    jest.resetAllMocks();
  });

  test('正向:首次请求落库缓存且返回深拷贝;随后命中缓存不触发http.fetch,TTL到期后自动刷新', async () => {
    const url = 'http://api/test';
    const ttlMs = 10_000; // 短 TTL 便于测试

    const data1 = { foo: 'bar', nested: { a: 1 } };
    http.fetch.mockResolvedValueOnce({
      status: 200,
      json: async () => data1,
    });

    // 首次请求:写缓存并返回深拷贝
    const res1 = await df.getJson(url, { ttlMs });
    expect(http.fetch).toHaveBeenCalledTimes(1);
    expect(http.fetch).toHaveBeenCalledWith(url, expect.objectContaining({ signal: expect.any(Object) }));
    expect(cache.set).toHaveBeenCalledTimes(1);
    const written1 = cache.set.mock.calls[0][1];
    expect(written1.data).toEqual(data1);
    expect(written1.expiresAt).toBe(Date.now() + ttlMs);

    // 返回深拷贝验证:修改返回值不影响缓存
    res1.nested.a = 999;
    expect(store.data.nested.a).toBe(1);

    // 随后命中缓存:不触发 http.fetch
    const res2 = await df.getJson(url);
    expect(http.fetch).toHaveBeenCalledTimes(1);
    // 再次深拷贝验证
    expect(res2).not.toBe(store.data);
    res2.foo = 'baz';
    expect(store.data.foo).toBe('bar');

    // TTL 到期后自动刷新:推进时间,触发新请求
    jest.advanceTimersByTime(ttlMs + 1);
    http.fetch.mockResolvedValueOnce({
      status: 200,
      json: async () => ({ foo: 'new' }),
    });
    const res3 = await df.getJson(url);
    expect(http.fetch).toHaveBeenCalledTimes(2);
    expect(cache.set).toHaveBeenCalledTimes(2);
    const written2 = cache.set.mock.calls[1][1];
    expect(store.data.foo).toBe('new');
    expect(written2.expiresAt).toBe(Date.now() + df.defaultTTL); // 本次未传 ttlMs,使用默认 TTL
  });

  test('重试与回退:5xx触发指数回退重试并在回退区间推进后成功', async () => {
    const url = 'http://api/retry';
    // 第一次 500,第二次 502,第三次成功
    http.fetch
      .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
      .mockResolvedValueOnce({ status: 502, json: async () => ({}) })
      .mockResolvedValueOnce({ status: 200, json: async () => ({ ok: true }) });

    const promise = df.getJson(url, { retry: 2 });

    // 初始执行,立即调用一次
    expect(http.fetch).toHaveBeenCalledTimes(1);

    // 等待第一段指数回退:1000ms
    jest.advanceTimersByTime(999);
    expect(http.fetch).toHaveBeenCalledTimes(1);
    jest.advanceTimersByTime(1);
    await Promise.resolve(); // 让微任务队列推进
    expect(http.fetch).toHaveBeenCalledTimes(2);

    // 第二段指数回退:2000ms
    jest.advanceTimersByTime(1999);
    expect(http.fetch).toHaveBeenCalledTimes(2);
    jest.advanceTimersByTime(1);
    await Promise.resolve();
    expect(http.fetch).toHaveBeenCalledTimes(3);

    const res = await promise;
    expect(res).toEqual({ ok: true });
    expect(cache.set).toHaveBeenCalledTimes(1);
  });

  test('重试与回退:网络错误达到最大重试后抛出最后错误', async () => {
    const url = 'http://api/retryfail';
    http.fetch
      .mockRejectedValueOnce(new Error('network'))
      .mockRejectedValueOnce(new Error('network'))
      .mockRejectedValueOnce(new Error('network'));

    const promise = df.getJson(url, { retry: 2 });

    // 第一段指数回退:1000ms
    expect(http.fetch).toHaveBeenCalledTimes(1);
    jest.advanceTimersByTime(1000);
    await Promise.resolve();
    expect(http.fetch).toHaveBeenCalledTimes(2);

    // 第二段指数回退:2000ms
    jest.advanceTimersByTime(2000);
    await Promise.resolve();
    expect(http.fetch).toHaveBeenCalledTimes(3);

    await expect(promise).rejects.toThrow('network');
    expect(cache.set).not.toHaveBeenCalled();
  });

  test('超时:较短 timeoutMs 触发 AbortController.abort 并重试后成功', async () => {
    const url = 'http://api/timeout-success';
    const abortSpy = jest.spyOn(global.AbortController.prototype, 'abort');

    // 第一次:模拟慢请求,等待 10s 才会返回,但 timeoutMs=50ms 必触发 abort
    const slowAbortFetch = jest.fn((u, opts) => {
      const { signal } = opts || {};
      return new Promise((resolve, reject) => {
        if (signal) {
          if (signal.aborted) return reject(new Error('abort'));
          signal.addEventListener('abort', () => reject(new Error('abort')), { once: true });
        }
        setTimeout(() => resolve({ status: 200, json: async () => ({ shouldNotReach: true }) }), 10_000);
      });
    });
    // 第二次:立即成功
    const fastFetch = jest.fn(() =>
      Promise.resolve({ status: 200, json: async () => ({ ok: true }) })
    );

    http.fetch
      .mockImplementationOnce(slowAbortFetch)
      .mockImplementationOnce(fastFetch);

    const promise = df.getJson(url, { timeoutMs: 50, retry: 1 });

    // 推进到超时,触发 abort
    jest.advanceTimersByTime(50);
    await Promise.resolve();
    expect(abortSpy).toHaveBeenCalledTimes(1);

    // 进入指数回退阶段:1000ms
    jest.advanceTimersByTime(1000);
    await Promise.resolve();

    // 第二次快速成功
    const res = await promise;
    expect(res).toEqual({ ok: true });
    expect(http.fetch).toHaveBeenCalledTimes(2);

    abortSpy.mockRestore();
  });

  test('超时:所有尝试均超时最终失败并抛出 abort 错误', async () => {
    const url = 'http://api/timeout-fail';
    const abortSpy = jest.spyOn(global.AbortController.prototype, 'abort');

    const slowAbortFetch = jest.fn((u, opts) => {
      const { signal } = opts || {};
      return new Promise((resolve, reject) => {
        if (signal) {
          if (signal.aborted) return reject(new Error('abort'));
          signal.addEventListener('abort', () => reject(new Error('abort')), { once: true });
        }
        setTimeout(() => resolve({ status: 200, json: async () => ({ shouldNotReach: true }) }), 10_000);
      });
    });

    http.fetch
      .mockImplementationOnce(slowAbortFetch)
      .mockImplementationOnce(slowAbortFetch); // 重试一次也超时

    const promise = df.getJson(url, { timeoutMs: 50, retry: 1 });

    // 第一次超时
    jest.advanceTimersByTime(50);
    await Promise.resolve();
    expect(abortSpy).toHaveBeenCalledTimes(1);

    // 回退 1000ms 后第二次启动并超时
    jest.advanceTimersByTime(1000);
    await Promise.resolve();

    jest.advanceTimersByTime(50);
    await Promise.resolve();
    expect(abortSpy).toHaveBeenCalledTimes(2);

    await expect(promise).rejects.toThrow('abort');
    expect(http.fetch).toHaveBeenCalledTimes(2);

    abortSpy.mockRestore();
  });

  test('非重试:4xx错误直接抛出且不再重试', async () => {
    const url = 'http://api/4xx';
    http.fetch.mockResolvedValueOnce({
      status: 404,
      json: async () => ({ msg: 'not found' }),
    });

    const promise = df.getJson(url, { retry: 3 });

    // 不应进入回退计时(虽然我们推进也不应触发再次 fetch)
    await expect(promise).rejects.toThrow('client_error:404');

    expect(http.fetch).toHaveBeenCalledTimes(1);
    jest.advanceTimersByTime(5000); // 推进计时也不会有更多调用
    expect(http.fetch).toHaveBeenCalledTimes(1);
    expect(cache.set).not.toHaveBeenCalled();
  });

  test('依赖模拟与语义校验:命中缓存不触发 http;forceRefresh=true 绕过缓存;expiresAt=now+ttl', async () => {
    const url = 'http://api/force';
    // 预写入缓存(未过期)
    store = {
      data: { v: 1 },
      expiresAt: Date.now() + 60_000,
    };

    // 命中缓存:不触发 http
    const resCached = await df.getJson(url);
    expect(resCached).toEqual({ v: 1 });
    expect(http.fetch).toHaveBeenCalledTimes(0);

    // forceRefresh=true:即便缓存有效也应触发请求并重写缓存
    const data2 = { v: 2 };
    http.fetch.mockResolvedValueOnce({
      status: 200,
      json: async () => data2,
    });

    const resRefresh = await df.getJson(url, { forceRefresh: true, ttlMs: 5000 });
    expect(http.fetch).toHaveBeenCalledTimes(1);
    expect(resRefresh).toEqual(data2);
    expect(cache.set).toHaveBeenCalledTimes(1);
    const written = cache.set.mock.calls[0][1];
    expect(written.data).toEqual(data2);
    expect(written.expiresAt).toBe(Date.now() + 5000);

    // 再次 forceRefresh:再次触发 http
    http.fetch.mockResolvedValueOnce({
      status: 200,
      json: async () => ({ v: 3 }),
    });
    const resRefresh2 = await df.getJson(url, { forceRefresh: true });
    expect(http.fetch).toHaveBeenCalledTimes(2);
    expect(resRefresh2).toEqual({ v: 3 });
    expect(cache.set).toHaveBeenCalledTimes(2);
  });
});

说明:

  • 使用 jest.useFakeTimers 和 jest.setSystemTime 控制 Date.now,从而对 TTL 与回退定时进行精确推进。
  • 通过 jest.fn() 模拟 http.fetch 多种响应场景:200、4xx、5xx、reject(new Error('network')),以及支持 Abort 的慢请求。
  • 通过内存变量 store 作为简易缓存实现,配合 cache.get/set 的 mock 验证缓存命中、写入次数与 expiresAt=now+ttl 语义。
  • 深拷贝断言通过修改返回对象并比较缓存中存储的数据是否未被影响。
  • 在超时场景中对 AbortController.prototype.abort 进行 spy,验证 abort 调用次数与 fetch 的 signal 参数。
  • 包含正向与反向用例、边界条件(超时、最大重试、4xx不重试、forceRefresh)与依赖项模拟,适合并入 CI 并生成稳定覆盖率。

示例详情

解决的问题

帮助开发人员快速生成高质量的单元测试代码,大幅提升代码覆盖率,节约时间和人力成本。

适用用户

测试开发工程师

帮助测试开发人员快速生成复杂系统的单元测试,覆盖更多场景并确保代码的高质量交付。

全栈开发者

为全栈工程师在忙碌的开发任务中节省单元测试编写时间,为代码快速上线保驾护航。

技术管理者

为团队节省研发成本,同时提高测试覆盖率,保障产品质量,助力项目按时交付。

特征总结

快速生成高质量单元测试,针对不同编程语言和测试框架自由适配。
智能识别代码逻辑,提供完整正向与反向测试用例,确保测试覆盖面全面。
支持边界条件测试和依赖项模拟,轻松覆盖复杂场景中的潜在问题。
针对具体业务场景(如支付、用户登录等)灵活定制测试内容和用例。
精准匹配测试框架和语言特色,无需繁琐配置,一键生成直接使用。
提供可拓展模板,用户可以轻松自定义和复用测试生成规则。
优化生成的测试代码可读性,便于开发团队快速理解与修改。
减少手动编写测试代码的时间和精力,让团队聚焦核心开发任务。

如何使用购买的提示词模板

1. 直接在外部 Chat 应用中使用

将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。

2. 发布为 API 接口调用

把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。

3. 在 MCP Client 中配置使用

在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。

AI 提示词价格
¥20.00元
先用后买,用好了再付款,超安全!

您购买后可以获得什么

获得完整提示词模板
- 共 142 tokens
- 6 个可调节参数
{ 测试框架 } { 编程语言 } { 代码片段 } { 测试场景描述 } { 依赖与模拟需求 } { 覆盖深度 }
获得社区贡献内容的使用权
- 精选社区优质案例,助您快速上手提示词
限时免费

不要错过!

免费获取高级提示词-优惠即将到期

17
:
23
小时
:
59
分钟
:
59