You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用Hypothesis测试含唯一约束字段的表单?

问题背景

我有一个包含三个字段联合唯一约束的Django模型:

class Bookshelf(models.Model):
    house = models.CharField(max_length=2, choices=HOUSE_CHOICES, default='Ar')
    room = models.CharField(max_length=20, choices=ROOM_CHOICES)
    name = models.CharField(max_length=200)
    compartments = models.IntegerField(choices=[(x, str(x)) for x in range(1, 9)])
    shelves = models.IntegerField(choices=[(x, str(x)) for x in range(1, 9)])

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['house', 'room', 'name'], name='unique_library')
        ]

class Book(models.Model):
    title = models.CharField(max_length=200)
    bookshelf = models.ForeignKey(Bookshelf, on_delete=models.PROTECT, blank=True, null=True)
    ...

还有一个需要用Hypothesis测试的ModelForm:

class SearchForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ['title', 'author_name', 'main_topic', 'subtopic', 'bookshelf', 'vote', 'support']

    def __init__(self, *args, **kwargs):
        super(SearchForm, self).__init__(*args, **kwargs)
        ...

测试时频繁出现不稳定行为(使用assume()或exist()时)或抛出IntegrityError。原因是Hypothesis创建的Bookshelf实例会在测试运行之间或同一次运行中遗留到数据库,重复创建相同(house, room, name)组合时违反唯一约束。尝试过多种方法但效果不佳:

from hypothesis.strategies import data as hypothesis_data

@composite
def search_form_data(draw):
    house = draw(st.text(min_size=1, max_size=10))
    room = draw(st.text(min_size=1, max_size=10))
    name = draw(st.text(min_size=1, max_size=10))

    bookshelf = Bookshelf.objects.create(
        house=house,
        room=room,
        name=name,
        compartments=2,
        shelves=5
    )
    # assume(not Bookshelf.objects.filter(house=house, room=room, name=name).exists())
    # if not Bookshelf.objects.filter(house=house, room=room, name=name).exists():
    
    return {
        'title': draw(st.text(min_size=0, max_size=30)),
        'author_name': draw(st.text(min_size=0, max_size=30)),
        'bookshelf': bookshelf.id,
    }


@given(data=search_form_data())
def test_search_form_accepts_random_valid_input(data):
    form = SearchForm(data)
    try:
        form.is_valid()
    except Exception as e:
        pytest.fail(f"Unexpected exception: {e}")


@given(
    unique_houses=st.sets(st.text(min_size=1, max_size=2), min_size=5, max_size=5),
    data=hypothesis_data()
)
def test_search_form_accepts_random_valid_input(unique_houses, data):
    for house in unique_houses:
        form_data = data.draw(search_form_data(house), label=f"form_data_for_{house}")
        form = SearchForm(form_data)

        try:
            form.is_valid()
        except Exception as e:
            pytest.fail(f"Unexpected exception: {e}")


@composite
def unique_bookshelf_triplet(draw):
    triplet_strategy = st.tuples(
        st.text(min_size=1, max_size=3),
        st.text(min_size=1, max_size=10),
        st.text(min_size=1, max_size=10),
    )
    seen = draw(st.shared(st.builds(set), key="triplets"))

    return draw(
        triplet_strategy
        .filter(lambda x: x not in seen)
        .map(lambda x: seen.add(x) or x)
    )

@given(data=hypothesis_data())
def test_create_unique_bookshelves(data):
    for _ in range(5):
        house, room, name = data.draw(unique_bookshelf_triplet())

        bookshelf = Bookshelf.objects.create(
            house=house,
            room=room,
            name=name,
            compartments=data.draw(st.integers(min_value=1, max_value=10)),
            shelves=data.draw(st.integers(min_value=1, max_value=10)),
        )

       assert bookshelf.pk is not None

不做检查会触发大量IntegrityError,做检查则测试不稳定(排除过多或结果不一致),求可行解决方案。

解决方案

1. 结合get_or_create避免重复创建

直接在策略中使用Django的get_or_create方法,既保证不会违反唯一约束,又能复用已有的实例,彻底避免IntegrityError:

from hypothesis.strategies import composite, st

@composite
def bookshelf_strategy(draw):
    # 生成符合模型约束的字段值
    house = draw(st.text(min_size=1, max_size=2))  # 匹配house字段max_length=2
    room = draw(st.text(min_size=1, max_size=20))
    name = draw(st.text(min_size=1, max_size=200))
    compartments = draw(st.integers(min_value=1, max_value=8))  # 匹配choices范围1-8
    shelves = draw(st.integers(min_value=1, max_value=8))
    
    # 用get_or_create确保唯一组合只创建一次
    bookshelf, _ = Bookshelf.objects.get_or_create(
        house=house,
        room=room,
        name=name,
        defaults={
            'compartments': compartments,
            'shelves': shelves
        }
    )
    return bookshelf

@composite
def search_form_data(draw):
    bookshelf = draw(bookshelf_strategy())
    return {
        'title': draw(st.text(min_size=0, max_size=200)),  # 匹配Book模型title字段的max_length
        'author_name': draw(st.text(min_size=0, max_size=30)),
        'bookshelf': bookshelf.id,
    }

@given(data=search_form_data())
def test_search_form_accepts_random_valid_input(data):
    form = SearchForm(data)
    assert form.is_valid(), f"Form validation failed with errors: {form.errors}"

2. 强化测试数据库隔离

利用Django的事务回滚机制,确保每个测试用例或Hypothesis生成的测试数据不会残留到下一次运行:

import pytest
from django.db import transaction

# 使用pytest-django的db标记,配合事务隔离
@pytest.mark.django_db(transaction=True)
@given(data=search_form_data())
def test_search_form_accepts_random_valid_input(data):
    with transaction.atomic():
        form = SearchForm(data)
        assert form.is_valid(), f"Form validation failed with errors: {form.errors}"

如果使用默认的Django测试类,它会自动在每个测试方法后回滚事务,也能达到隔离效果。

3. 用st.unique生成全局唯一三元组

改进之前的唯一策略,使用Hypothesis内置的st.unique自动保证生成的(house, room, name)组合不重复,无需手动维护seen集合:

@composite
def unique_bookshelf_triplet(draw):
    triplet_strategy = st.tuples(
        st.text(min_size=1, max_size=2),
        st.text(min_size=1, max_size=20),
        st.text(min_size=1, max_size=200)
    )
    # 用st.unique确保每个三元组唯一
    return draw(st.unique(triplet_strategy, by=lambda x: x))

@composite
def search_form_data(draw):
    house, room, name = draw(unique_bookshelf_triplet())
    compartments = draw(st.integers(min_value=1, max_value=8))
    shelves = draw(st.integers(min_value=1, max_value=8))
    
    bookshelf = Bookshelf.objects.create(
        house=house,
        room=room,
        name=name,
        compartments=compartments,
        shelves=shelves
    )
    return {
        'title': draw(st.text(min_size=0, max_size=200)),
        'author_name': draw(st.text(min_size=0, max_size=30)),
        'bookshelf': bookshelf.id,
    }

4. 生成符合模型约束的合法数据

直接从模型的choices中采样字段值,既保证数据合法,又能减少重复概率:

@composite
def bookshelf_strategy(draw):
    # 从HOUSE_CHOICES中采样合法的house值
    house = draw(st.sampled_from([choice[0] for choice in HOUSE_CHOICES]))
    # 从ROOM_CHOICES中采样合法的room值
    room = draw(st.sampled_from([choice[0] for choice in ROOM_CHOICES]))
    name = draw(st.text(min_size=1, max_size=200))
    compartments = draw(st.sampled_from(range(1,9)))
    shelves = draw(st.sampled_from(range(1,9)))
    
    bookshelf, _ = Bookshelf.objects.get_or_create(
        house=house,
        room=room,
        name=name,
        defaults={
            'compartments': compartments,
            'shelves': shelves
        }
    )
    return bookshelf

这种方式生成的数据完全符合模型约束,同时结合get_or_create,彻底解决唯一冲突问题。

内容的提问来源于stack exchange,提问作者fabio

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.12 21:05:55