Computer Engineering/Django

Django DB Transaction 3편 - DB Transaction Test 코드 작성하기.

jordan.bae 2022. 2. 2. 17:33

Transaction makes safe system.

Introduction

안녕하세요. 1편과 2편에 이어서 마지막 편으로 Django에서 DB Transaction과 관련된 코드들의 테스트 코드를 작성하는 방법에 대해서 포스팅 해보려고 합니다.

아직 1편과 2편을 읽지 않으신 분이 계신다면 먼저 읽고 오시는 것을 추천드립니다. Django에서 DB Transaction을 어떻게 사용하는지 그리고 실제 DB에서 어떤 동작이 일어나는지를 이해하시고 이번 글을 읽으시면 이해하기 쉽고 더 도움이 될 거라고 생각합니다.

 

지난 글

Django DB Transaction(트랜잭션) 1편 - Request와 DB Transaction 묶기(Feat. ATOMIC_REQUESTS)

Django DB Transaction 2편 - 명시적으로 transaction 활용하기. (feat. savepoint)

 

그럼 본격적으로 1,2편에서 살펴본 DB Transaction과 관련된 기능들을 어떻게 테스트할지에 대한 이야기를 시작해보도록 하겠습니다.

 

*주의 이 글은 Django4.0 및 MySQL5.7 기준의 글입니다.*

 

Django's TestCase

Django에서 지원하는 TestCase는 아래와 같은 Hierarchy를 가지고 있습니다.

출처: https://docs.djangoproject.com/en/4.0/topics/testing/tools/#django.test.TestCase

우리가 집중적으로 볼 부분은 Transaction과 관련된 부분입니다. 그렇기 때문에 Database에 관련된 기능을 구현한 TransactionTestCase와 TransactionTestCase를 상속받은 TestCase가 transaction과 관련해서 어떻게 동작하는지 살펴볼 것 입니다. TransactionTestCase와 TestCase는 테스트 간의 데이터를 reset하는 방법을 제외하고는 동일합니다.

 

TransactionTestCase

- 테스트 수행 후에 모든 테이블을 truncate 합니다.

- 테스트에서 Transaction의 commit이나 rollback을 관찰 할 수 있다.

- 단점은 각 테스트 간의 모든 테이블 지워야 하므로 테스트 시간이 오래 걸린다.

 

TestCase 

- 테스트 수행 후에 모든 테이블을 truncate하지 않고, 각 test를 transaction으로 처리하여 test 수행 후에 transaction을 rollback합니다. 우리가 ATOMIC_REQUESTS를 사용하여 request handler의 시작 전에 transaction을 시작하고, response를 반환 후에 commit or rollback을 하던 것 과 같은 전략입니다. 즉, TestCase안에서는 tranaction.atomic block이나 atomic_request 설정이 savepoint로 동작하게 됩니다. 이 방법의 장점은 성능입니다. (truncate보다 transaction rollback이 빠릅니다.)

-> 하지만, savepoint가 있기 때문에 같은 DB connection의 transaction 안에서는 부분별로 transaction이 잘 동작하는지 확인할 수도 있습니다. 아래 예제 코드를 통해서 살펴볼 예정입니다. 하지만, 어쨌든 실제 Production과는 다르게 동작하므로 이 부분은 주의해야 될 것 같습니다.

 

예제 코드로 실제 동작 살펴보기

1,2 편에서 사용했던 코드를 계속 사용하겠습니다.

먼저 models.py, serializers.py, views.py 코드입니다. 아래 코드는 안 보셔도 되고 다만 아래와 같은 코드를 테스트하는 것이 때문에 참고만 하시면 됩니다.

# models.py
from django.db import models
from django.contrib.auth.models import User


class Company(models.Model):
    ticker = models.CharField(max_length=16)
    name = models.CharField(max_length=64)


class Account(models.Model):
    owner = models.ForeignKey(User, on_delete=models.PROTECT, db_constraint=False)

    created_dt = models.DateTimeField(auto_now_add=True)
    updated_dt = models.DateTimeField(auto_now=True)

    def __repr__(self):
        return f"{self.owner} Account"


class Shares(models.Model):
    account = models.ForeignKey(Account, on_delete=models.PROTECT, db_constraint=False)
    company = models.ForeignKey(Company, on_delete=models.CASCADE, db_constraint=False)
    amount = models.PositiveBigIntegerField()

    created_dt = models.DateTimeField(auto_now_add=True)
    updated_dt = models.DateTimeField(auto_now=True)

    class Meta:
        unique_together = [['account', 'company']]


class SharesTransfer(models.Model):
    account_from = models.ForeignKey(
        Account, on_delete=models.PROTECT, db_constraint=False, related_name='account_from'
    )
    account_to = models.ForeignKey(
        Account, on_delete=models.PROTECT, db_constraint=False, related_name='account_to'
    )
    company = models.ForeignKey(Company, on_delete=models.PROTECT, db_constraint=False)
    amount = models.PositiveBigIntegerField()

    created_dt = models.DateTimeField(auto_now_add=True)
    updated_dt = models.DateTimeField(auto_now=True)

# serializers.py
from django.db.models import F
from rest_framework import serializers

from .models import Shares, SharesTransfer


class SharesTransferSerializer(serializers.Serializer):
    # validation 은 생략
    account_from = serializers.IntegerField()
    account_to = serializers.IntegerField()
    company_id = serializers.IntegerField()
    amount = serializers.IntegerField()

    def create(self, validated_data, *args, **kwargs):
        # 보낸 사람의  주식을 줄인다.
        Shares.objects.filter(account_id=validated_data.get('account_from'), company_id=validated_data.get('company_id'))\
            .update(amount=F('amount') - validated_data.get('amount'))
        # 받은 사람의 주식을 늘린다.
        Shares.objects.filter(account_id=validated_data.get('account_to'), company_id=validated_data.get('company_id'))\
            .update(amount=F('amount') + validated_data.get('amount'))
        # 전송내역을 저장한다.
        transfer = SharesTransfer.objects.create(
            account_from_id=validated_data.get('account_from'),
            account_to_id=validated_data.get('account_to'),
            company_id=validated_data.get('company_id'),
            amount=validated_data.get('amount')
        )
        return transfer
        

# views.py

from rest_framework import viewsets, permissions
from rest_framework.response import Response
from rest_framework import status

from .serializers import SharesTransferSerializer


def send_email():
    print('send_email_for_testing')


class ShareTransferViewSet(viewsets.GenericViewSet):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = SharesTransferSerializer
    lookup_field = 'id'

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        serializer.save()
        # for transaction test
        send_email()
        return Response('success', status=status.HTTP_201_CREATED)

 

위와 같은 API의 integration test를 작성해보겠습니다.

테스트 코드는 아래와 같습니다. 저희가 살펴 볼 테스트는 test_create_fail_with_exception_for_checking_transaction 입니다. 해당 테스트에서는 send_email에서 exception이 발생시켜서 request에 대한 response가 실패하도록 의도적으로 만들고 있습니다.

from unittest.mock import patch

from django.urls import reverse
from django.test import TestCase, TransactionTestCase
from rest_framework import status
from rest_framework.test import APITestCase, APIClient
from django.contrib.auth.models import User
from trading import views

from trading.models import Account, Company, Shares, SharesTransfer


class ShareTransferViewSetTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        super(ShareTransferViewSetTests, cls).setUpTestData()
        cls.username1, cls.password1 = 'tester', 'tester1234'
        cls.username2, cls.password2 = 'tester2', 'tester12342'

        cls.user1 = User.objects.create_user(username=cls.username1, password=cls.password1)
        cls.user2 = User.objects.create_user(username=cls.username2, password=cls.password2)

        # fixture dataset
        cls.company = Company.objects.create(ticker='AMZN', name='amazon')
        cls.account1 = Account.objects.create(owner=cls.user1)
        cls.account2 = Account.objects.create(owner=cls.user2)
        cls.shares1 = Shares.objects.create(account=cls.account1, company=cls.company, amount=100)
        cls.shares2 = Shares.objects.create(account=cls.account2, company=cls.company, amount=100)

    def setUp(self) -> None:
        # for session authenticating
        self.client = APIClient()
        self.client.login(username=self.username1, password=self.password1)

    def test_create_success(self) -> None:
        url = reverse('transfer-list')
        amount = 10
        data = {
            "account_from": self.account1.id,
            "account_to": self.account2.id,
            "company_id": self.company.id,
            "amount": amount
        }
        response = self.client.post(url, data=data, format='json')
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.json(), "success")

    @patch.object(views, "send_email")
    def test_create_fail_with_exception_for_checking_transaction(self, mock_send_email):
        mock_send_email.side_effect = Exception("test error")
        url = reverse('transfer-list')
        amount = 10
        data = {
            "account_from": self.account1.id,
            "account_to": self.account2.id,
            "company_id": self.company.id,
            "amount": amount
        }
        before_shares1_amount = self.shares1.amount
        before_shares2_amount = self.shares2.amount

        with self.assertRaises(Exception):
            self.client.post(url, data=data, format='json')

        # checking transaction working well (http response와 묶여서 DB에 반영이 안됨)
        self.shares1.refresh_from_db()
        self.shares2.refresh_from_db()
        self.assertEqual(self.shares1.amount, before_shares1_amount)
        self.assertEqual(self.shares2.amount, before_shares2_amount)

이를 통해서 아래와 같이 4가지 상황에 대해서 살펴볼 예정입니다.

ATOMIC_REQUESTS가 True일 때와 False일 때.

TestCase를 사용할 때 TransactionTestCase를 사용할 때.

 

1) TestCase상속,  ATOMIC_REQUESTS=True 일 때

TestCase를 사용했기 때문에 test시작 부분에서 autocommit=0으로 transaction을 시작하고, 마지막에 rollback하는 것을 볼 수 있다.

테스트 결과: 성공

Request 자체가 Savepoint로 묶여서 exception이 발생했으므로 rollback되어서 해당 트랜잭션내에서 바뀌지 않은것으로 처리됨. 하지만 실제 코드에서는 Transaction으로 묶이기 때문에 테스트는 성공하지만 실제와 다르게 동작한다는 단점이 있음.

DB에 수행되는 SQL

# test시작되는 부분
151 Query	SET autocommit=0
....
# response가 시작되는 부분
151 Query	SAVEPOINT `s140327450969920_x6`
151 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`username` = 'tester' LIMIT 21
151 Query	SELECT (1) AS `a` FROM `django_session` WHERE `django_session`.`session_key` = 'wu2y79xbx12kvvfg4u4f2dnbfbzlivf4' LIMIT 1
151 Query	SAVEPOINT `s140327450969920_x7`
151 Query	INSERT INTO `django_session` (`session_key`, `session_data`, `expire_date`) VALUES ('wu2y79xbx12kvvfg4u4f2dnbfbzlivf4', 'e30:1nFA3t:0qG48fhGDWhOHPkGIEk5g04yU4mhmtwqLp-YIPEIHkk', '2022-02-16 07:28:17.729747')
151 Query	RELEASE SAVEPOINT `s140327450969920_x7`
151 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-02-02 07:28:17.733107' AND `django_session`.`session_key` = 'wu2y79xbx12kvvfg4u4f2dnbfbzlivf4') LIMIT 21
151 Query	SELECT (1) AS `a` FROM `django_session` WHERE `django_session`.`session_key` = '3m4sv2d4ib7gqzjq4wxvtltb4a8g9rfg' LIMIT 1
151 Query	SAVEPOINT `s140327450969920_x8`
151 Query	INSERT INTO `django_session` (`session_key`, `session_data`, `expire_date`) VALUES ('3m4sv2d4ib7gqzjq4wxvtltb4a8g9rfg', 'e30:1nFA3t:0qG48fhGDWhOHPkGIEk5g04yU4mhmtwqLp-YIPEIHkk', '2022-02-16 07:28:17.736853')
151 Query	RELEASE SAVEPOINT `s140327450969920_x8`
151 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE `django_session`.`session_key` = 'wu2y79xbx12kvvfg4u4f2dnbfbzlivf4' LIMIT 21
151 Query	DELETE FROM `django_session` WHERE `django_session`.`session_key` IN ('wu2y79xbx12kvvfg4u4f2dnbfbzlivf4')
151 Query	UPDATE `auth_user` SET `last_login` = '2022-02-02 07:28:17.743379' WHERE `auth_user`.`id` = 1
151 Query	SAVEPOINT `s140327450969920_x9`
151 Query	UPDATE `django_session` SET `session_data` = '.eJxVjEEOwiAQRe_C2hARB4pL956BDDODVA0kpV0Z726bdKHb_977bxVxmUtcukxxZHVRRh1-t4T0lLoBfmC9N02tztOY9KbonXZ9ayyv6-7-HRTsZa3zOTAaT-SZA3kBy56TdSk7czJCAg7FBkAYgHII4ehoAOtgNckZoz5fB6c4IA:1nFA3t:xI_WUQANWnZCwZ3_TsPHVojj53iQkEkDCNplsUHeijQ', `expire_date` = '2022-02-16 07:28:17.745177' WHERE `django_session`.`session_key` = '3m4sv2d4ib7gqzjq4wxvtltb4a8g9rfg'
151 Query	RELEASE SAVEPOINT `s140327450969920_x9`
151 Query	SAVEPOINT `s140327450969920_x10`
151 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-02-02 07:28:17.751501' AND `django_session`.`session_key` = '3m4sv2d4ib7gqzjq4wxvtltb4a8g9rfg') LIMIT 21
151 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 21
151 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 10) WHERE (`trading_shares`.`account_id` = 1 AND `trading_shares`.`company_id` = 1)
151 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 10) WHERE (`trading_shares`.`account_id` = 2 AND `trading_shares`.`company_id` = 1)
151 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (1, 2, 1, 10, '2022-02-02 07:28:17.760093', '2022-02-02 07:28:17.760164')
151 Query	RELEASE SAVEPOINT `s140327450969920_x10`
151 Query	ROLLBACK TO SAVEPOINT `s140327450969920_x6`
# response가 끝나는 부분
151 Query	RELEASE SAVEPOINT `s140327450969920_x6`
....
# 테스트 끝나는 부분
151 ROLLBACK
151 SET autocommit=1

 

2) TestCase상속,  ATOMIC_REQUESTS=False 일 때

테스트 결과: 실패

해당 트랜잭션내에서 select해서 phantom read하여 바뀐 것 처럼 보임. 하지만 다른 transaction에서 조회했을 때는 그래로이다.(isolation level별로 다르지만, 보통 우리는 READ UNCOMMITED는 사용하지 않기 때문에..)

TestCase class를 사용하면 이런 문제가 있다...(Transaction동작에 대해서 정확히 이해하기 힘듬.)

 

Error message

FAILED tests/trading/api/test_share_transfer_view_set.py::ShareTransferViewSetTests::test_create_fail_with_exception_for_checking_transaction - AssertionError: 90 != 100

DB에 실행되는 SQL

# Test 시작부분
160 Query	SET autocommit=0
160 Query	INSERT INTO `auth_user` (`password`, `last_login`, `is_superuser`, `username`, `first_name`, `last_name`, `email`, `is_staff`, `is_active`, `date_joined`) VALUES ('pbkdf2_sha256$260000$bxFgW9t5ASFHwLNKVpnSXZ$ZoX/UECeECQ/MXQ9zGuZhclxegf/17vH+MOK9as3UlY=', NULL, 0, 'tester', '', '', '', 0, 1, '2022-02-02 07:36:52.657499')
160 Query	INSERT INTO `auth_user` (`password`, `last_login`, `is_superuser`, `username`, `first_name`, `last_name`, `email`, `is_staff`, `is_active`, `date_joined`) VALUES ('pbkdf2_sha256$260000$crb0qlMQEvlzA8i0y9wLRJ$DHm4UktINgeYHp9txkvTRI5wZ8nrFoXWLnvRypKZ6oc=', NULL, 0, 'tester2', '', '', '', 0, 1, '2022-02-02 07:36:52.898442')
160 Query	INSERT INTO `trading_company` (`ticker`, `name`) VALUES ('AMZN', 'amazon')
160 Query	INSERT INTO `trading_account` (`owner_id`, `created_dt`, `updated_dt`) VALUES (1, '2022-02-02 07:36:53.127459', '2022-02-02 07:36:53.127497')
160 Query	INSERT INTO `trading_account` (`owner_id`, `created_dt`, `updated_dt`) VALUES (2, '2022-02-02 07:36:53.131104', '2022-02-02 07:36:53.131260')
160 Query	INSERT INTO `trading_shares` (`account_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (1, 1, 100, '2022-02-02 07:36:53.134858', '2022-02-02 07:36:53.135043')
160 Query	INSERT INTO `trading_shares` (`account_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (2, 1, 100, '2022-02-02 07:36:53.137689', '2022-02-02 07:36:53.137739')
160 Query	SAVEPOINT `s139711800366912_x1`
160 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`username` = 'tester' LIMIT 21
160 Query	SELECT (1) AS `a` FROM `django_session` WHERE `django_session`.`session_key` = 'dqnu7uq5edpix55dckwu5qp07osbuxck' LIMIT 1
160 Query	SAVEPOINT `s139711800366912_x2`
160 Query	INSERT INTO `django_session` (`session_key`, `session_data`, `expire_date`) VALUES ('dqnu7uq5edpix55dckwu5qp07osbuxck', 'e30:1nFACD:_6rx3SAb8VF8GaVx0-0QGMk9tIU97SQ6Nr2F53h4Brw', '2022-02-16 07:36:53.345378')
160 Query	RELEASE SAVEPOINT `s139711800366912_x2`
160 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-02-02 07:36:53.350622' AND `django_session`.`session_key` = 'dqnu7uq5edpix55dckwu5qp07osbuxck') LIMIT 21
160 Query	SELECT (1) AS `a` FROM `django_session` WHERE `django_session`.`session_key` = '914zlc01k9aa40ci59gjs3pklewdxd0l' LIMIT 1
160 Query	SAVEPOINT `s139711800366912_x3`
160 Query	INSERT INTO `django_session` (`session_key`, `session_data`, `expire_date`) VALUES ('914zlc01k9aa40ci59gjs3pklewdxd0l', 'e30:1nFACD:_6rx3SAb8VF8GaVx0-0QGMk9tIU97SQ6Nr2F53h4Brw', '2022-02-16 07:36:53.358372')
160 Query	RELEASE SAVEPOINT `s139711800366912_x3`
160 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE `django_session`.`session_key` = 'dqnu7uq5edpix55dckwu5qp07osbuxck' LIMIT 21
160 Query	DELETE FROM `django_session` WHERE `django_session`.`session_key` IN ('dqnu7uq5edpix55dckwu5qp07osbuxck')
160 Query	UPDATE `auth_user` SET `last_login` = '2022-02-02 07:36:53.366915' WHERE `auth_user`.`id` = 1
160 Query	SAVEPOINT `s139711800366912_x4`
160 Query	UPDATE `django_session` SET `session_data` = '.eJxVjDsOwjAQBe_iGlnrT2wvJT1nsNY_HEC2FCcV4u4QKQW0b2bei3na1uq3kRc_J3Zmgp1-t0DxkdsO0p3arfPY27rMge8KP-jg157y83K4fweVRv3W2hYKU0EALVRBJRyCic7lIG0wIVkqjjJhsiCMUpNGqdAUnQs40BLY-wPUyzcf:1nFACD:kJm3I5Tqi0pU0Bwx_P8Yld6sl77R54-h1th4sBmaEWQ', `expire_date` = '2022-02-16 07:36:53.369080' WHERE `django_session`.`session_key` = '914zlc01k9aa40ci59gjs3pklewdxd0l'
160 Query	RELEASE SAVEPOINT `s139711800366912_x4`
160 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-02-02 07:36:53.793298' AND `django_session`.`session_key` = '914zlc01k9aa40ci59gjs3pklewdxd0l') LIMIT 21
160 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 21
160 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 10) WHERE (`trading_shares`.`account_id` = 1 AND `trading_shares`.`company_id` = 1)
160 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 10) WHERE (`trading_shares`.`account_id` = 2 AND `trading_shares`.`company_id` = 1)
160 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (1, 2, 1, 10, '2022-02-02 07:36:53.804298', '2022-02-02 07:36:53.804350')
160 Query	SELECT `trading_shares`.`id`, `trading_shares`.`account_id`, `trading_shares`.`company_id`, `trading_shares`.`amount`, `trading_shares`.`created_dt`, `trading_shares`.`updated_dt` FROM `trading_shares` WHERE `trading_shares`.`id` = 1 LIMIT 21
160 Query	SELECT `trading_shares`.`id`, `trading_shares`.`account_id`, `trading_shares`.`company_id`, `trading_shares`.`amount`, `trading_shares`.`created_dt`, `trading_shares`.`updated_dt` FROM `trading_shares` WHERE `trading_shares`.`id` = 2 LIMIT 21
160 Query	ROLLBACK TO SAVEPOINT `s139711800366912_x1`
160 Query	RELEASE SAVEPOINT `s139711800366912_x1`
160 Query	ROLLBACK

# test가 끝나면서 rollback 됨.
160 Query	SET autocommit=1
160 Quit

 

3) TransactionTestCase상속,  ATOMIC_REQUESTS=True 일 때

우선 TransactionTestCase를 사용하면 setUpTestData class method를 사용할 수 없다. 테스트 별로 truncate table이 수행되기 때문에 transaction단위로 reset을 하는 TestCase 클래스에서 사용하는 class method이기 때문이다.

그래서 아래와 같이 각 test 별로 실행하는 setUp 메서드에 초기 데이터를 생성하는 코드를 넣어야 한다.

    def setUp(self) -> None:
        self.username1, self.password1 = 'tester', 'tester1234'
        self.username2, self.password2 = 'tester2', 'tester12342'

        self.user1 = User.objects.create_user(username=self.username1, password=self.password1)
        self.user2 = User.objects.create_user(username=self.username2, password=self.password2)

        # fixture dataset
        self.company = Company.objects.create(ticker='AMZN', name='amazon')
        self.account1 = Account.objects.create(owner=self.user1)
        self.account2 = Account.objects.create(owner=self.user2)
        self.shares1 = Shares.objects.create(account=self.account1, company=self.company, amount=100)
        self.shares2 = Shares.objects.create(account=self.account2, company=self.company, amount=100)

        # for session authenticating
        self.client = APIClient()
        self.client.login(username=self.username1, password=self.password1)

 

테스트 결과: 성공

실제 우리의 production 환경에서 동작하는대로 response handler 시작 부분에서 transaction이 사작된다.

그리고 exception이 발생했기 때문에 마지막에 rollback까지 되서 예상대로 동작한 것을 확인할 수 있다.

또, 위에서 살펴본 것 처럼 테스트 후 모든 테이블을 지우는 것을 확인할 수 있다.

 

DB에 실행되는 SQL

# response 시작
173 Query	SET autocommit=0
173 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-02-02 08:02:40.966071' AND `django_session`.`session_key` = 'pg91k1gjinhmlvk6682g51lkswhj3osk') LIMIT 21
173 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 21
173 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 10) WHERE (`trading_shares`.`account_id` = 1 AND `trading_shares`.`company_id` = 1)
173 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 10) WHERE (`trading_shares`.`account_id` = 2 AND `trading_shares`.`company_id` = 1)
173 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (1, 2, 1, 10, '2022-02-02 08:02:40.980863', '2022-02-02 08:02:40.980912')
# response과정에서 exception이 발생해서 rollback
173 Query	ROLLBACK
173 Query	SET autocommit=1
173 Query	SELECT `trading_shares`.`id`, `trading_shares`.`account_id`, `trading_shares`.`company_id`, `trading_shares`.`amount`, `trading_shares`.`created_dt`, `trading_shares`.`updated_dt` FROM `trading_shares` WHERE `trading_shares`.`id` = 1 LIMIT 21
173 Query	SELECT `trading_shares`.`id`, `trading_shares`.`account_id`, `trading_shares`.`company_id`, `trading_shares`.`amount`, `trading_shares`.`created_dt`, `trading_shares`.`updated_dt` FROM `trading_shares` WHERE `trading_shares`.`id` = 2 LIMIT 21
173 Query	SHOW FULL TABLES
173 Query	SET autocommit=0
173 Query	SET FOREIGN_KEY_CHECKS = 0
# test 수행 후 모든 테이블 truncate
173 Query	DELETE FROM `django_admin_log`
173 Query	DELETE FROM `auth_user_groups`
173 Query	DELETE FROM `auth_user_user_permissions`
173 Query	DELETE FROM `django_session`
173 Query	DELETE FROM `event_analyticsevent`
173 Query	DELETE FROM `trading_sharestransfer`
173 Query	DELETE FROM `auth_group_permissions`

 

 

4) TransactionTestCase상속,  ATOMIC_REQUESTS=False 일 때

테스트 결과: 실패

FAILED tests/trading/api/test_share_transfer_view_set.py::ShareTransferViewSetTests::test_create_fail_with_exception_for_checking_transaction - AssertionError: 90 != 100

동작하는 테스트 코드는 위와 같다. 크게 예상과는 다를 것이 없다. 트랜잭션으로 묶이는 부분이 없기 때문에 response에서 exception이 발생했지만 DB에는 잘 반영되는 것을 볼 수 있다. TestCase도 ATOMIC_REQUEST=False일 때 실패했지만 정확한 동작은 다르다. 이번에는 다른 transaction에서 조회하더라도 같은 결과를 볼 수 있다.

 

DB에 실행되는 SQL

# autocommit=1 이기 때문에 sql마다 바로 commit됨.
178 Query	SET autocommit=1
178 Query	SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`expire_date` > '2022-02-02 08:05:15.733374' AND `django_session`.`session_key` = '04mlonkuwc55gaqugd0sere8drm9ytbu') LIMIT 21
178 Query	SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 3 LIMIT 21
178 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` - 10) WHERE (`trading_shares`.`account_id` = 3 AND `trading_shares`.`company_id` = 2)
178 Query	UPDATE `trading_shares` SET `amount` = (`trading_shares`.`amount` + 10) WHERE (`trading_shares`.`account_id` = 4 AND `trading_shares`.`company_id` = 2)
178 Query	INSERT INTO `trading_sharestransfer` (`account_from_id`, `account_to_id`, `company_id`, `amount`, `created_dt`, `updated_dt`) VALUES (3, 4, 2, 10, '2022-02-02 08:05:15.750129', '2022-02-02 08:05:15.750191')
178 Query	SHOW FULL TABLES
178 Query	SET autocommit=0
178 Query	SET FOREIGN_KEY_CHECKS = 0

# 각 테스트 수행 후 테이블 truncate
178 Query	DELETE FROM `django_session`
178 Query	DELETE FROM `blog_post`
178 Query	DELETE FROM `django_admin_log`
....

 

마무리

이번 편에서는 Django에서 제공하는 TestClass들을 활용해서 Transaction과 관련된 테스트를 진행해 봤습니다. 정확한 Transaction의 동작을 테스트 하기 위해서 TransactionTestCase를 사용하는 것이 좋습니다. 하지만, 정확한 이해를 하고 있다는 것을 바탕으로는 TestCase class로도 어느 정도까지는 Savepoint를 활용하여 테스트가 가능합니다. 

 

Django DB Transaction 포스팅 마무리 하면서..

이 번 편을 마지막으로 Django DB Transaction과 관련된 포스팅을 마치려고 합니다. 원래는 4편으로 나눠서 쓰려고 했는데 SAVEPOINT가 2편에 같이 포스팅하면서 3편으로 줄었습니다. Django에서 지원하는 DB Transaction과 관련된 부분을 잘 전달하려고 노력했는데 생각보다 글을 잘 작성하는 것도 어렵고, 정리하다보면 제가 모르는 부분도 많이 있어서 부족한 부분이 많은 것 같습니다. Django 공식 문서가 정말 정리가 잘 되어 있기 때문에 중간에 이해가 안 가시는 부분이 있으면 같이 참고하셔도 좋을 듯 합니다. 제가 조금 신경 쓴 부분은 실제 Database에서 어떻게 동작하는지를 최대한 같이 설명하려고 노력했습니다. 읽어 주셔서 감사하고, 혹시 글과 관련되서 궁금한 점이나 잘 못된 부분을 알려주시면 반영하도록 하겠습니다! 또, Django와 관련된 공유나 활동(스터디, 발표등)들이 많이 생기면 좋겠다는 생각을 하고 있는데 관심있는 분들은 알려주세요!

 

 

반응형