@Lenciel

Making fixture with factory boy and faker

我们在Django项目的开发和测试过程中经常需要mock一些数据作为fixture,比较常见的做法是:

  1. 进行一些操作创建测试数据
  2. 使用dumpdata命令导出json格式的数据
  3. 以导出的json为模板构造测试数据用loaddata命令导入到数据库

这样对于大多数场景也算够用了,但是你总会遇到某一天客户走来说:“我想看看那个报表生成出来啥样,能不能创建两千条记录?”

这种时候你大概你第一反应是把之前那个json搞来copy-paste出两千份数据。但很快你就会意识到那是不行的:要构建一个对象,你常常需要先构建它外键的对象,而实际上线的项目它的数据库结构是非常复杂的(数据库结构图的生成见这里),所以构建两千条记录的工作量会远远超过你的想象:

schemaSpy

最近本座试用了factory boyfaker的组合,感觉还比较好用。

Factory Boy

最开始找这类批量生成测试数据的库,主要考察的是Model MommyFactory Boy。看了一下文档感觉两者的差别并不算很大,但是Factory Girl里面的Sienna Miller实在是让人过目不忘所以有什么好犹豫的呢?

Factories的文档上说明了基本的用法,需要注意的主要是如何生成有一定依赖关系的一组测试对象。

数据构造

Factory Boy下的数据构造主要是通过SequenceFuzz两个包来完成。

Sequence故名思议是顺序生成的,比如你要让生成的数据有规律的用户名和电话号码,这样你看到电话13000000001就是是对应user0001

1
2
user = Sequence(lambda n: u'user%04d' % n)
phone = Sequence(lambda n: u'1300000%04d' % n)

Fuzz则是随机的,主要用来构造像学校、专业或者生日这样的数据:

1
2
3
4
5
6
7
8
9
10
11
card_bank = FuzzyChoice([u'中国银行', u'中国招商银行', u'中国工商银行',
                      u'中国建设银行', u'成都银行'])
major = FuzzyChoice([u'地球物理学', u'大气科学', u'海洋科学', u'力学',
                  u'农业工程', u'环境科学', u'心理学', u'统计学',
                  u'系统科学', u'地矿', u'机械', u'仪器仪表',
                  u'能源动力', u'电气信息', u'土建', u'测绘',
                  u'环境与安全', u'化工与制药', u'交通运输', u'海洋工程;',
                  u'航空航天', u'武器', u'工程力学', u'生物工程',
                  u'公安技术', u'材料科学', u'材料', u'水利',
                  u'林业工程', u'轻工纺织食品', u'电子信息科学', u'其他'])
birthday = FuzzyNaiveDateTime(dt.datetime(1992, 1, 1), dt.datetime(1996, 1, 1))

当然,有的字段,比如姓名、地址这类通过顺序或者是随机的从某个设定的集合抽取效果都不够理想,后面会看到怎么用faker来构造它们。

关联对象生成

关联对象的关系有很多种(1:1, 1:n, n:1, n:n),主要都是通过组合运用SubFactoryRelatedFactory两者来生成,但具体的构造方式和先构造谁都要以实际情况而定。比如我们有User和Tester这样的1:1的关系:

1
2
3
4
5
6
class Tester(TimeBaseModel):

    user = models.OneToOneField(User,
                                verbose_name=u'账号',
                                related_name='tester')
    ...

这里在考虑是在TesterFactory里面把User作为SubFactory来生成,还是在UserFactory里面把Tester作为RelatedFactory来生成,主要就是看先后关系。很显然,在这里我们应该先构造系统里的User:

1
2
3
class TestUserFactory(UserFactory):
    ...
    tester = RelatedFactory('apps.tester.factories.TesterFactory', 'user')

这段代码告诉系统,在每个TestUser被构造的时候,用构造出来的user来创建一个1:1的Tester。这个Tester的构造会在usersave之前完成。

然后在Tester的构造过程中你可以直接通过SelfAttribute使用传入的user:

1
2
3
4
5
class TesterFactory(DjangoModelFactory):
    ...
    phone = SelfAttribute('user.phone')
    nick_name = SelfAttribute('user.nick_name')
    creator = SelfAttribute('user')

再比如,我们的TesterPlatformTask都会关联到测试任务TesterTask,它们俩看起来都是ForeinKey

1
2
3
4
5
6
7
class TesterTask(TestingDeviceMixin, TimeBaseModel):
    owner = models.ForeignKey(Tester,
                              verbose_name=u'测试人', )

    platform_task = models.ForeignKey(PlatformTask,
                                      verbose_name=u'任务',
                                      related_name=u'tester_tasks')

但对生成数据而言,我们的目标会是每个Tester在被创建的时候,都给它创建一个以这个TesterownerTesterTask,并且给这个TesterTask创建一个关联的PlatformTask

于是我们的写法就会是,首先在TesterFactory里面使用RelatedFactory来创建TesterTask:

1
2
3
4
class TesterFactory(DjangoModelFactory):
    ...
    tester_task = RelatedFactory('apps.tester.factories.TesterTaskFactory', 'owner')
    ...

然后在TesterTaskFactory里面创建PlatformTask,并且在构造的时候使用传入的owner的参数:

1
2
3
4
5
6
class TesterTaskFactory(DjangoModelFactory):
    ...
    platform_task = SubFactory('apps.platformtask.factories.PlatformTaskFactory',
                               company=SelfAttribute('..owner.user.company'),
                               owner=SelfAttribute('..owner.user'))
    ...

faker

有很多字段,比如姓名、地址这些,纯粹用Fuzz的办法很难做到“贴近真实”。faker就是用来解决这类字段的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from faker import Factory
fake = Factory.create()

fake.name()
# 'Lucy Cechtelar'

fake.address()
# "426 Jordy Lodge
#  Cartwrightshire, SC 88120-6700"

fake.text()
# Sint velit eveniet. Rerum atque repellat voluptatem quia rerum. Numquam excepturi
# beatae sint laudantium consequatur. Magni occaecati itaque sint et sit tempore. Nesciunt
# amet quidem. Iusto deleniti cum autem ad quia aperiam.
# A consectetur quos aliquam. In iste aliquid et aut similique suscipit. Consequatur qui
# quaerat iste minus hic expedita. Consequuntur error magni et laboriosam. Aut aspernatur
# voluptatem sit aliquam. Dolores voluptatum est.
# Aut molestias et maxime. Fugit autem facilis quos vero. Eius quibusdam possimus est.
# Ea quaerat et quisquam. Deleniti sunt quam. Adipisci consequatur id in occaecati.
# Et sint et. Ut ducimus quod nemo ab voluptatum.

这个包最可爱的地方就是支持本地化,比如一个随机的中文姓名可以这么去构造:

1
2
faker = FakerFactory.create('zh_CN')
name = lazy_attribute(lambda x: faker.name())

生成fixture

因为factory boyfaker主要的作用是在测试里面去mock数据,所以要用它们生成fixture不是那么容易。这是因为Django的整个设计上就很注意避免你把测试的数据写到生产的数据库,所以测试都会在一个在Setup阶段被创建,在TearDown阶段被删除的临时数据库里面进行(我看了一下,在开发版本的Django上已经加了一个--keepdb的参数使得你可以保留你用来运行测试的数据库了)。

所以我们可以在一个测试的Setup阶段把数据生成后,直接调用dumpdata命令来把数据dump出去:

1
2
3
4
5
6
7
8
9
def setUp(self):
    company = CompanyFactory.create(id=3)
    TestUserFactory.create(company=company, id=3000)
    TestUserFactory.create_batch(company=company, size=1500)

    #for test_user in test_users:

    create_fixture('tester', 'tester.json')
    create_fixture('account', 'account.json')

注意,这里在创建的时候指定id主要是为了让初始的id比较大,避免和系统里面已经有的id撞车导致你构造的测试数据在loaddata的时候报错或者覆盖现有数据。

其中,create_fixture函数内容如下:

1
2
3
4
5
6
def create_fixture(app_name, filename):
    buf = StringIO()
    management.call_command('dumpdata', app_name, indent=4, stdout=buf)
    buf.seek(0)
    with open(filename, 'w') as f:
        f.write(buf.read().encode('utf-8'))