如何使用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
相关产品推荐
相关产品推荐

