mizzsugar’s blog

日々感じていることや学んだことを書きます。エンジニアリング以外にも書くかもしれません。

【Django】複数のファイルをまとめてテストするとTransactionErrorやIntegurityErrorになってしまう事件について

仕事でテスト周りについて色々あったので備忘録として

例えば、こういうViewとModelがあったとして・・・

(まあ、こんなこと普通しないとは思いつつ簡単な例を出したく)

models.py

from typing import Dict

from django.db import models
from django.db import IntegrityError


class Customer(models.Model):
    GENDER_CHOICES = (
        (0, 'Male'),
        (1, 'Female'),
        (2, 'Other')
    )
    name = models.TextField()
    email = models.EmailField(unique=True)
    birthday = models.DateField()
    gender = models.PositiveSmallIntegerField(choices=GENDER_CHOICES)


    @classmethod
    def create(cls, data: Dict) -> None:
        """フォームのデータから顧客を新規登録するメソッド
        
        dataの中身
        {
            'name': 'name',
            'email': 'email@example.com',
            'birthday': datetime.date(1990, 1, 1),
            'gender': 0
        }
        """
        try:
            cls.objects.create(
                name=data.get('name'),
                email=data.get('email'),
                birthday=data.get('birthday'),
                gender=data.get('gender')
            )
        except IntegrityError:
            raise

views.py

from django.http import HttpResponse
from django.views import View
from django.shortcuts import render
from django import forms
from django.db import IntegrityError

from .models import Customer

# ブログを簡素にしたいのでViewに書いたけどforms.pyに書いてもよし
class CustomerForm(forms.Form):
    GENDER_CHOICES = (
        (0, 'Male'),
        (1, 'Female'),
        (2, 'Other')
    )
    name = forms.CharField(max_length=255)
    email = forms.EmailField()
    gender = forms.ChoiceField(choices=GENDER_CHOICES)


class CreateCustomerView(View):
    def get(self, request):
        """顧客名一覧を表示
        """
        costomers = [
            customer.name
            for customer in Customer.objects.iterator()
        ]
        return render(
            request,
            'form.html',
            {
                'costomers': costomers,
                'form': CustomerForm()
            }
        )

    def post(self, request):
        """顧客を新規登録

        request.postの形式
        {
            "name": "Taro Tanaka",
            "birthday": "1980-01-01",
            "gender": 0
        }
        """
        form = CustomerForm(request.POST)
        if form.is_valid():
            try:
                Customer.create(data=form.cleaned_data)
            except IntegrityError:
                form.add('email', 'すでに登録しているメールアドレスは登録できません')
        return render(
            requset,
            'form.html',
            {
                'costomers': costomers,
                'form': form
            }
        )

こうテストを書くと

test_models.py

import datetime

from django.db import IntegrityError
from django.test import TestCase

from .models import Customer


class TestCustomerModel(TestCase):
    @classmethod
    def setUpTestData(cls):
        Customer.objects.create(
            name='Taro Yamada',
            email='sample@example.com',
            birthday=datetime.date(1980, 1, 1),
            gender=0
        )

    def test_email_duplicate(self):
        data = {
            name: 'Jiro Yamada',
            email: 'sample@example.com',  # すでに登録しているメールアドレス
            birthday: datetime.date(1980, 2, 2),
            gender: 0
        }
        with self.assertRaises(IntegrityError):
            Customer.create(data=data)

    def test_create_valid_customer(self):
        data = {
            name: 'Hanako Takada',
            email: 'sample_2@example.com',
            birthday: datetime.date(1980, 3, 3),
            gender: 1
        }
        Customer.create(data=data)

test_views.py

from django.test import TestCase, Client

from .models import Customer
from .views import CreateCustomerView


class TestCustomerView(TestCase):
    @classmethod
    def setUpTestData(cls):
        Customer.objects.create(
            name='Taro Yamada',
            email='sample@example.com',
            birthday=datetime.date(1980, 1, 1),
            gender=0
        )

    def test_email_duplicate(self):
        data = {
            name: 'Jiro Yamada',
            email: 'sample@example.com',  # すでに登録しているメールアドレス
            birthday: datetime.date(1980, 2, 2),
            gender: 0
        }
        response = self.client.post(
            '',
            {
                "name": "Taro Yamada",
                "email": "sample@example.com",
                "gender": "0",
                "birthday": "1980-01-01",
                "position": "100",
            }
        )
        self.assertContains(
            'すでに登録しているメールアドレスは登録できません'
            response
        )

TransactionErrorが〜

IntegurityErrorが〜


・・・なぜ起こるか。

TestCaseを継承するクラスにsetUpClassとtearDownClassがないことに注目。

Djangoでは、setUpClassがそのテストクラスでテストを実行するための準備をするために、テストクラスが初期化される際に一度だけ呼ばれます。

tearDownClassがテストクラス内のテストがすべて終わりテストクラスが解放される際に一度だけ呼ばれる仕組みになっています。tearDownClassはsetUpClassが行った準備をすべてクリーンにするイメージかと思います。

DjangoのTestCaseを継承している場合、tearDownClassによって、トランザクションロールバックされます。テスト用データベース内のデータがすべて一掃されます。

上記の例だと、

各テストクラスにsetUpClassとtearDownClassがないため、データベースのトランザクションが終わってない状態であるがゆえにTransactionError

最初に実行したテストクラスでのデータが残っているためユニークであるはずのデータが重複しようとしてしまい意図しないIntegurityError・・・

となってしまいました。

じゃあこうすればいいの!?  

class TestCustomerModel(TestCase):
    @classmethod
    def setUpClass(cls):
        pass

# 中略
...
    
    @classmethod
    def tearDownClass(cls):
        pass

いいえ、違います。

ただ最初にsetUpClassとtearDownClassを追加しただけだとトランザクション開始と後処理の働きをしないので super()をつけることによってDjangoのTestCaseのsetUpClassとtearDownClassの働きを継承してあげましょう。

class TestCustomerModel(TestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()

# 中略
...
    
    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()

他にもテストクラス内のテストメソッドは順番関係なく実行されてもOKなように・・・などありますが

他のファイルが影響するということは避けられるようになりました!

(Viewのテストは実際にトランザクション走らせずモック使っても・・・というのはまた別の記事で!)

<参考>

qiita.com