1
4
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

[初心者]Python Djangoで単体テスト(unittest)書いて開発[Factory Boy][Mock]

Last updated at Posted at 2024-07-06

1. はじめに

この記事では、Djangoを使ってシンプル(もどき)なECサイトを構築し、そのサイトに対して単体テストをどのように書くかについて学びます!
Pythonの日本語の教材って意外と単体テスト書いてくれてないので結構な分量で書きました。
ヘッダー画像.jpg

一応、超簡単に単体テスト書くといいこと触れときます。
コードの量が増えれば増えるほど機能追加や変更が恐ろしくなります。単体テスト書くと完璧ではないですが、リリースするのが少し自信出ます😉
開発初期では書く方が開発遅くなると思いますが、書かないと次第に辛くなっていきます(体験済み)ので身につけておいて損はないですよ!

簡単なアプリ作成経験あると仮定して作っていきます。経験ないと言う方は昔書いた記事を参考に

この記事で学べること!

  1. 実践的なDjangoアプリケーションの構築: 商品一覧ページ、カート機能、注文機能を含むシンプルなECサイトを作成します。
  2. 単体テストの基本を学ぶ: Djangoのユニットテストフレームワークのunittestを使って、アプリケーションの各部分を個別にテストする方法を学びます。
  3. Mockオブジェクトの使用: Djangoのビューやメール送信機能をテストする際に、外部依存を取り除くためのMockオブジェクトの使い方を学びます。
  4. Factory Boyを使いってCRUDのテストします。
    • 初心者用の教材はfixture使う気がするのですが、こちらの方が便利です。意外と記事がないので売りです。
    • RailsだとFactory Bot(Factory Girl)が使われるみたい?ですが、それのPythonバージョンです。

pytestの方が良くない?というツッコミある気がしますが、Python以外の言語やってる人ならunittestの方がわかりやすいと言う意図です。pytestの方が作りも書き方もPythonっぽいんですよね。
ただ、見る人多そうだったら今後書きます。

単体テストでどんなものを書くか

この記事では、以下のような単体テストを書いていきます。

  • モデルのテスト: Djangoモデルのメソッドなどが正しく動作することを確認します。
  • ビューのテスト: Djangoビューが正しいレスポンスを返し、正しいテンプレートをレンダリングすることを確認します。
  • テンプレートのテスト: テンプレートが正しい内容を表示することを確認します。
  • メール送信のテスト: 注文完了時に送信される確認メールが正しい内容で送信されることを確認します。この際にMockオブジェクトを使ってメール送信をシミュレートします。

Mockも学べる

意外と、Mockを使った単体テストには触れないこと多いです。ただ、開発では使う場面が多いと思います。
Mockオブジェクトを使うことで、外部のサービス(例えばメールサーバー)に依存しないテストを実行できます。この記事では、メール送信機能をMockする具体的な方法を紹介します。これにより、実際のメールを送信せずにメールの内容や送信先が正しいことを確認できます。

注意事項とやらないこと

  • アプリ作成の細かいコマンドや構造などには触れません。
  • Djangoには関数ベースとclassベースがあるのですが、どちらも書いていこうと思います
    • 関数ベースのほうがコードを追いやすいので最初はそちらで書いて後でclassベースで書きます
  • 余計なことはなるべく書かないほうが内容に集中できると思うのでcssは書きません
  • 単体テストを試すための機能開発なので動くようには作っていますが不十分なところあります。ただ、どういうふうに単体テストを書いていくかは勉強なります
  • 海外在住のエンジニアです。コード英語で書いたところありますが修正面倒で修正してません🙇

2. サンプルECサイトの構築と単体テスト

前置き長かったですが、Djangoを使用して簡単なECサイトを作成し、その単体テストを書く方法について説明します。

2.1. 環境設定

単体テストと本題と外れますが、環境構築もしてちゃんと動くDjangoアプリ作っていきます!

記事作成時の環境

  • Mac
  • Django 5.0.6
  • factory-boy 3.3.0

何も考えずに、書いたとき最新だったDjangoをしようしました。Djangoが3でも4でも動くと思いますが、検証はしてないです。お気をつけください。

2.2. Djangoプロジェクトの作成

まず、Djangoプロジェクトを作成します。今回は表人でついているものを使いますが、pipenvとかつかってお大丈夫です。

  1. 仮想環境の作成と有効化:

    python -m venv myenv
    source myenv/bin/activate  # Windowsの場合は `myenv\Scripts\activate`
    
  2. Djangoのインストール:

    pip install django
    
  3. Djangoプロジェクトの作成:

    django-admin startproject myproject
    cd myproject
    

2.3. アプリケーションの作成

次に、storeという名前のアプリケーションを作成します。

python manage.py startapp store

2.4. 必要なパッケージのインストール

ユニットテストを実行するために必要なパッケージをインストールします。
テストデータを作りやすくしてくれるパッケージです。

pip install factory_boy

2.5. テスト用の設定ファイルの確認と調整

myproject/settings.pyファイルを確認し、テスト用の設定を追加します。

myproject/settings.py

INSTALLED_APPS = [
    ...
    'store',
    ...
]

# Djangoのデフォルトテストランナー
TEST_RUNNER = 'django.test.runner.DiscoverRunner'

は書かなくても、問題ないはずです。動かなかったら記述してみてください。

3. ECサイトもどきアプリ作成

下準備できたので単体テストを行うためのコードを書いていきましょう。

3.1. モデルの作成

ECサイトの基本的なモデルを作成します。ここでは、ProductモデルとOrderモデルを定義します。

store/models.py
from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)

    def __str__(self):
        return self.name

class Order(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField()

    def total_price(self):
        return self.product.price * self.quantity

    def __str__(self):
        return f"Order of {self.product.name}"

3.2. ビューの作成

次に、ビューを作成します。ここでは、シンプルに商品一覧を表示するビューを作成します。

store/views.py

from django.shortcuts import render
from .models import Product

def product_list(request):
    products = Product.objects.all()
    return render(request, 'store/product_list.html', {'products': products})

3.3. テンプレートの作成

テンプレートを作成します。商品一覧を表示するテンプレートを作成します。

store/templates/store/product_list.html
<!DOCTYPE html>
<html>
<head>
    <title>Product List</title>
</head>
<body>
    <h1>Product List</h1>
    <ul>
        {% for product in products %}
            <li>{{ product.name }} - ¥{{ product.price }}</li>
        {% endfor %}
    </ul>
</body>
</html>

3.4. URL設定

アプリケーションのURL設定を行います。
store/urls.pyにファイルを作成してください。

store/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.product_list, name='product_list'),
]

プロジェクトのURL設定を更新します。
元からあるmyproject/urls.pyに追記します。

myproject/urls.py

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('store/', include('store.urls')),
]

3.5. データベースのマイグレーション

  1. マイグレーションファイルの作成:
    モデルを定義した後、マイグレーションファイルを作成します。

    python manage.py makemigrations
    
  2. マイグレーションの適用:
    マイグレーションファイルを適用して、データベースに変更を反映させます。

    python manage.py migrate
    

3.6. 開発サーバーの起動

  1. 開発サーバーの起動:
    開発サーバーを起動します。

    python manage.py runserver
    

3.7. 作成したページの確認

  1. ページの確認:
    ブラウザでhttp://127.0.0.1:8000/store/にアクセスし、作成した商品一覧ページが正しくレンダリングされているか確認します。

    • 商品一覧ページには、データベースに登録された商品の名前と価格がリスト表示されます。

3.8. 手順の確認

一応手順まとめておきます。
マイグレーションファイルの作成

実行コマンド:

python manage.py makemigrations

出力例:

Migrations for 'store':
  store/migrations/0001_initial.py
    - Create model Product
    - Create model Order

マイグレーションの適用

実行コマンド:

python manage.py migrate

出力例:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, store
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK
  Applying store.0001_initial... OK

開発サーバーの起動

実行コマンド:

python manage.py runserver

出力例:

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
June 27, 2024 - 16:39:43
Django version 5.0.6, using settings 'myproject.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

ブラウザでの確認

ブラウザでhttp://127.0.0.1:8000/store/にアクセスし、次のような商品一覧ページが表示されているか確認します。

管理画面で何も登録していないのでタイトルだけが表示されているはずです。
スクリーンショット 2024-06-28 1.50.16.png

これで、データベースのマイグレーションを行い、開発サーバーを起動して作成したページが正しくレンダリングされていることを確認できます。問題がなければ、ECサイトの基本的なセットアップが完了です。

でも、データが存在しないので何も表示できていません。これだと正しいのかわかりませんよね。データを管理画面から追加してみましょう。

3.9. 管理画面を使用する・admin.pyの設定

storeアプリケーションのadmin.pyファイルを編集して、ProductOrderモデルを管理サイトに登録します。

store/admin.py
from django.contrib import admin
from .models import Product, Order

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ('name', 'price')

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('product', 'quantity', 'total_price')

上記のコードでは、ProductOrderモデルをDjango管理サイトに登録し、それぞれのモデルに表示するフィールドを設定しています。

3.9.1. 管理サイトへのアクセス

3.9.2. スーパーユーザーの作成:

Django管理サイトにアクセスするためには、スーパーユーザーを作成する必要があります。

python manage.py createsuperuser

コマンドを実行すると、ユーザー名、メールアドレス、パスワードを入力するように求められます。
サンプルのアプリでローカル環境なのでなんでもいいです。Passwordは何も表示されないと思いますが、問題ありませんので入力してください。私はSample123456789にしました。

Username (leave blank to use 'dev'): user1
Email address: sample@sample.com
Password:
Password (again):
Superuser created successfully.

サーバーの起動:
開発サーバーを起動します。

python manage.py runserver

3.9.3. 管理サイトでデータ登録:

ブラウザでhttp://127.0.0.1:8000/admin/にアクセスし、作成したスーパーユーザーの資格情報でログインします。これで、ProductOrderモデルを管理できるようになります。

ダークモード切り替えできますので表示される色違うかも。

スクリーンショット 2024-06-28 1.45.44.png

日本語表記に今回はしていません。settings.pyをいじれば日本語表示にできます。
先ほど設定した値を入力してください。

データを追加しましょう。
スクリーンショット 2024-06-28 1.51.39.png

Product → add
自由に登録してください。

スクリーンショット 2024-06-28 1.52.45.png

何個か登録しました。
スクリーンショット 2024-06-28 1.53.52.png

データベースにデータが追加できましたので、作成した画面で確認しましょう
http://127.0.0.1:8000/store/

スクリーンショット 2024-06-28 1.56.14.png

表示がとてつもなくダサいですが、正常にうごいるのが確認できましたね!

3.9.4. ファイル構成

確認用に、プロジェクトの全体的なファイル構成を示します。

myproject/
├── manage.py
├── myproject/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   ├── wsgi.py
│   └── asgi.py
└── store/
    ├── __init__.py
    ├── admin.py
    ├── apps.py
    ├── models.py
    ├── tests.py
    ├── urls.py
    ├── views.py
    └── templates/
        └── store/
            └── product_list.html

storeの中に、urls.pyと、templatesのproduct_list.htmlのフォルダ構造だけ注意してください。
admin.pyにモデルを登録することで、Django管理サイトからモデルデータを簡単に管理できるようになりました。これにより、データの追加、編集、削除が容易になり、テストデータの作成や管理もスムーズに行えます。これで、基本的なECサイトの構築と管理が可能になりました。

3.10. テストケースの作成

やっとですが、factory_boyを使用したテストを作成してみます!!

3.10.1. factory_boyを使用したテストケース

まず、factory_boyを使ってファクトリを定義します。
factories.pyを作成してください。

store/factories.py
import factory
from .models import Product, Order

class ProductFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Product

    name = factory.Faker('name')
    price = factory.Faker('pydecimal', left_digits=5, right_digits=2, positive=True)

class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order

    product = factory.SubFactory(ProductFactory)
    quantity = factory.Faker('random_int', min=1, max=10)

次に、tests.pyファイルでファクトリを使用してテストデータを作成します。

store/tests.py
from django.test import TestCase
from .models import Product, Order
from .factories import ProductFactory, OrderFactory

class ProductTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)

    def test_product_creation(self):
        self.assertEqual(self.product.name, 'Test Product')
        self.assertEqual(self.product.price, 10.00)

class OrderTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.order = OrderFactory(product=self.product, quantity=2)

    def test_order_creation(self):
        self.assertEqual(self.order.product.name, 'Test Product')
        self.assertEqual(self.order.quantity, 2)

    def test_order_total_price(self):
        self.assertEqual(self.order.total_price(), 20.00)

class ProductListViewTestCase(TestCase):
    def setUp(self):
        self.product1 = ProductFactory(name='Test Product 1', price=10.00)
        self.product2 = ProductFactory(name='Test Product 2', price=20.00)

    def test_product_list_view(self):
        response = self.client.get('/store/')
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'Test Product 1')
        self.assertContains(response, 'Test Product 2')

setUp()は各テストケースを実行する前に実行される処理です。
例えば、OrderTestCaseを実行するとtest_order_creationtest_order_total_priceを実行する前それぞれで実行されます。
productテーブルとorderテーブルにデータを1つずつ追加してからテストを始めているわけです。

そのため、今回は影響ないですが、IDには注意して下さい。

  • test_order_creation実行時
    • product.id: 1, order.id: 1
  • test_order_total_price実行時
    • product.id: 2, order.id: 2

となっています。別のレコードを使用しているわけです。今回は使いませんが、class単位で一度だけ最初に実行して欲しい場合は以下を使用してください

@classmethod
    def setUpTestData(self):

3.10.2. テストの実行

最後に、作成したテストを実行します。

python manage.py test
Found 4 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.011s

OK
Destroying test database for alias 'default'...

factory_boyを使用してテストデータを生成するようになりました。fixture使うよりもテストデータの生成が簡潔かつ柔軟になり、テストケースの読みやすさと保守性が向上します。
簡単にコードの説明をしていきますよ!

3.10.3. factories.pyについての説明

factories.pyは、factory_boyライブラリを使ってテストデータを簡単に生成するためのファクトリを定義するファイルです。ファクトリを使うことで、テストデータを自動的かつ柔軟に生成することができます。

3.10.4. factories.pyの内容

Djangoで使うファイルでなく、単体テストのデータを作るために便利なfactory boy使うために使うfactories.pyの内容を説明します。

store/factories.py
import factory
from .models import Product, Order

class ProductFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Product

    name = factory.Faker('name')
    price = factory.Faker('pydecimal', left_digits=5, right_digits=2, positive=True)

class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order

    product = factory.SubFactory(ProductFactory)
    quantity = factory.Faker('random_int', min=1, max=10)

3.10.5. 各部分の説明

  1. インポート文:
    import factory
    from .models import Product, Order
    
    • factoryfactory_boyライブラリのインポートです。
    • ProductOrderは、テストデータを生成するためのDjangoモデルです。

これは説明するまでもない気はしますが、、、一応。

  1. ProductFactoryの定義:
    class ProductFactory(factory.django.DjangoModelFactory):
        class Meta:
            model = Product
    
        name = factory.Faker('name')
        price = factory.Faker('pydecimal', left_digits=5, right_digits=2, positive=True)
    
    • ProductFactoryは、Productモデルのインスタンスを生成するためのファクトリです。
    • class Meta内で、model属性にProductモデルを指定しています。
    • nameフィールドにはfactory.Faker('name')を使用してランダムな名前を生成します。
    • priceフィールドにはfactory.Faker('pydecimal')を使用してランダムな価格を生成します。

つまり、Factoryとか言ってますが、unittestするときにつかうProductモデルです。同じだと思っておいてOKです。
今回の場合は、namepriceを何も指定しないで作るとデフォルトで入れる値を定義してます。

Factory使わないと、モデルの構造を頭に入れて一つ一つ書かないといけないわけです。
※ fixtreというyamlとかの別のファイルに別で記述する場合も同じ

これ作ってるのと同じようなものです。
Product.objects.create(name='ああああ', price='123')

今回、値を検証しないといけないのでProductFactory(name='Test Product', price=10.00)のようにちゃんと書きましたが、コードが追加されていくと、nameとpriceは検証の対象でない場合がでます。
もしくは、テストのデータ上はProductのデータは必要だけど、中身はどうでもいいなんてことが頻繁に発生します。

そんなときにはproduct = ProductFactory()だけでいい場面もあるわけです。

  1. OrderFactoryの定義:
    class OrderFactory(factory.django.DjangoModelFactory):
        class Meta:
            model = Order
    
        product = factory.SubFactory(ProductFactory)
        quantity = factory.Faker('random_int', min=1, max=10)
    
    • OrderFactoryは、Orderモデルのインスタンスを生成するためのファクトリです。
    • class Meta内で、model属性にOrderモデルを指定しています。
    • productフィールドにはfactory.SubFactory(ProductFactory)を使用して関連するProductインスタンスを生成します。
    • quantityフィールドにはfactory.Faker('random_int')を使用してランダムな数量を生成します。

SubFactoryはわかりにくいと思うのですが、リレーション担っているのでOrderFactoryだけを作った場合に、Productのリレーションががない!と怒られてエラーになるので必要です。
これを手動で毎回理解して単体テスト書くのはなかなか大変なのですが、サボれます。

Productの情報もテストしたいという場合はProductFactory()してあげる必要があります。

3.10.6. factory_boyを使う場合と使わない場合で、何が変わったか

factory_boyを使わない場合

factory_boyを使わない場合、テストデータを手動で作成する必要があります。以下は、factory_boyを使わない場合の例です。コードは書かなくていいですよ。

store/tests.py
from django.test import TestCase
from .models import Product, Order

class ProductTestCase(TestCase):
    def setUp(self):
        self.product = Product.objects.create(name='Test Product', price=10.00)

    def test_product_creation(self):
        self.assertEqual(self.product.name, 'Test Product')
        self.assertEqual(self.product.price, 10.00)

class OrderTestCase(TestCase):
    def setUp(self):
        self.product = Product.objects.create(name='Test Product', price=10.00)
        self.order = Order.objects.create(product=self.product, quantity=2)

    def test_order_creation(self):
        self.assertEqual(self.order.product.name, 'Test Product')
        self.assertEqual(self.order.quantity, 2)

    def test_order_total_price(self):
        self.assertEqual(self.order.total_price(), 20.00)

factory_boyを使う場合

factory_boyを使う場合、ファクトリを使ってテストデータを自動的に生成できます。以下は、factory_boyを使った場合の例です。

store/tests.py
from django.test import TestCase
from .models import Product, Order
from .factories import ProductFactory, OrderFactory

class ProductTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)

    def test_product_creation(self):
        self.assertEqual(self.product.name, 'Test Product')
        self.assertEqual(self.product.price, 10.00)

class OrderTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.order = OrderFactory(product=self.product, quantity=2)

    def test_order_creation(self):
        self.assertEqual(self.order.product.name, 'Test Product')
        self.assertEqual(self.order.quantity, 2)

    def test_order_total_price(self):
        self.assertEqual(self.order.total_price(), 20.00)

class ProductListViewTestCase(TestCase):
    def setUp(self):
        self.product1 = ProductFactory(name='Test Product 1', price=10.00)
        self.product2 = ProductFactory(name='Test Product 2', price=20.00)

    def test_product_list_view(self):
        response = self.client.get('/store/')
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'Test Product 1')
        self.assertContains(response, 'Test Product 2')

3.10.7. 何が変わったのか

あんまりかわんないやん!!って声聞こえそうですが、DBが現状は単純で、ロジックも単純だからどっちでも変わりません。開発が進めば進むほど利点がわかるようになると思います。
一般的には

  1. テストデータの生成が簡潔:

    • factory_boyを使うと、ファクトリを使ってテストデータを簡潔に生成できます。これにより、テストコードが簡潔で読みやすくなります。
  2. 柔軟性の向上:

    • factory_boyは、テストデータを柔軟にカスタマイズできます。例えば、フィールドごとにランダムなデータを生成したり、関連するオブジェクトを自動的に作成したりできます。
  3. 再利用性の向上:

    • ファクトリを使うことで、同じテストデータ生成ロジックを複数のテストで再利用できます。これにより、コードの重複を減らし、保守性が向上します。

factory_boyを使うことで、テストデータの生成が簡単になり、テストコードの簡潔性、柔軟性、再利用性が向上します。特に複雑なデータ構造や依存関係のあるデータを扱う場合に有用です。これにより、テストの作成と保守が容易になり、テストの質が向上します。

3.11. テストの実行

最後に、作成したテストを実行します。

python manage.py test

3.11.1. 失敗することも確認しよう

本来、単体テストを作るときは最初に、失敗することを確認して、成功するテストを確認することが一般的です。
適時、失敗するコードに書き換えて一度実行してみてください。
理由は、毎回必ず成功するテストは全く意味ないから失敗するのも確認必要というわけです。

store/tests.py
class ProductTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)

    def test_product_creation(self):
        self.assertEqual(self.product.name, 'Test Product1')
        self.assertEqual(self.product.price, 10.00)

Test Productという文字列がProductテーブルに保存されているのですが、これを'Test Product1'にしたことで失敗します。

python manage.py test
Found 4 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...F
======================================================================
FAIL: test_product_creation (store.tests.ProductTestCase.test_product_creation)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/dev/study2/unittest_article/myproject/store/tests.py", line 11, in test_product_creation
    self.assertEqual(self.product.name, 'Test Product1')
AssertionError: 'Test Product' != 'Test Product1'
- Test Product
+ Test Product1
?             +


----------------------------------------------------------------------
Ran 4 tests in 0.011s

FAILED (failures=1)
Destroying test database for alias 'default'...

'Test Product' != 'Test Product1'とエラー出てますね。

3.11.2. 簡易まとめ

この記事では、Djangoを使って簡単なECサイトを作成し、その単体テストを書く方法を紹介しました。基本的なプロジェクトの作成から、モデル、ビュー、テンプレートの定義、URL設定、テストケースの作成、そしてテストの実行までの一連の流れを学びました。

補足: Djangoのテスト環境について

気になった方もいるかもと思ったので補足します。 テストでデータ作成してローカルの環境のデータ変更されたりしないの??と思う方もいると思ったので。

Djangoでテストを実行する際、テスト用のデータベースが自動的に作成され、テストデータはそのテスト用データベースに登録されます。これは、実際のデータベースがテストによって汚染されないようにするためです。

1. テストの開始:

python manage.py testを実行すると、Djangoはまずテスト用のデータベースを作成します。このデータベースは、settings.pyで指定されたデータベース設定に基づいて構成されますが、デフォルトではSQLiteが使用されます。もちろんMySqlやpostgresqlも設定すれば使えます。

2. テストデータの登録:

テストケース内で生成されるデータはすべてこのテスト用データベースに登録されます。テストデータの生成には、手動でモデルのインスタンスを作成する方法と、factory_boyを使用して自動的に生成する方法があります。

3. テストの終了:

テストが終了すると、テスト用データベースは削除されます。これにより、テストデータが実際のデータベースに影響を与えることはありません。

補足2: テスト時間かかる場合、特定のテストだけしたい場合

テストが増えてくると、全てのテストを実行するのに時間がかかる場合があります。このような場合に特定のテストだけを実行したり、テストを高速化する方法について説明します。

特定のテストケースを実行する

Djangoのテストコマンドでは、特定のテストケースやメソッドを指定して実行することができます。以下にその方法を示します。

  • 特定のテストケースを実行する:

ファイルやclassを指定できます。以下はテストclassを指定しています。

python manage.py test store.tests.ProductTestCase
  • 特定のテストメソッドを実行する:
    python manage.py test store.tests.ProductTestCase.test_product_creation
    

これにより、必要なテストだけを実行することができます。
ちゃんとsetUp()も実行されます。(当然と思うかもしれませんが、一応)

不要なテストのスキップ

テストが特定の条件でのみ実行されるように設定することで、不要なテストの実行を回避できます。

  • 特定の条件でテストをスキップ:
    from django.test import TestCase
    import unittest
    
    class MyTestCase(TestCase):
        @unittest.skipUnless(condition, "Skip message")
        def test_example(self):
            self.assertTrue(True)
    

書いておいてなんですが、これをやると意味がなくなるので基本は使わないこと。
長年単体テストを書かなくなっていて、少しずつ動く単体テスト書いていこうという流れになって行ったときくらいでしょうか、、、

3.12. ビューのテスト

脇道に結構それていましたが、viewのテストも書きます。
ビューのテストでは、Djangoのテストクライアントを使用してビューが正しく動作しているかを確認します。

store/tests.py
from django.test import TestCase
from django.urls import reverse
from .models import Product
from .factories import ProductFactory

class ProductListViewTestCase(TestCase):
    def setUp(self):
        self.product1 = ProductFactory(name='Test Product 1', price=10.00)
        self.product2 = ProductFactory(name='Test Product 2', price=20.00)

    def test_product_list_view(self):
        response = self.client.get(reverse('product_list'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'Test Product 1')
        self.assertContains(response, 'Test Product 2')

こんなふうにtests.pyに書いてOKです。

3.12.1. ビューのテスト項目

ビューとテンプレートの単体テストはそれぞれ異なる目的を持ち、異なる観点からテストするといいです。
ビューのテストでは、以下の項目をテストすることが一般的です。結構いろんな宗派とか考えありそうですけどね。

  1. ステータスコードの確認:

    • ビューが正しいHTTPステータスコードを返すかどうかを確認します。
  2. テンプレートの使用確認:

    • ビューが正しいテンプレートを使用しているかどうかを確認します。
  3. コンテキストデータの確認:

    • ビューがテンプレートに渡しているコンテキストデータが正しいかどうかを確認します。
  4. リダイレクトの確認:

    • 必要に応じて、ビューが適切にリダイレクトされるかどうかを確認します。

3.12.2. ビューのテスト改造

modelのテストもviewのテストも一つのファイルに書いていました。それでもいいんですけど、テストケースが増えると探すのも書くのも大変になったりしますのでテストファイルを分けましょう。

testsディレクトリを作成してmyproject/store/tests/__init__.pyを作成。空のファイルでいいです。その後、viewのテストを移します。

store/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from ..factories import ProductFactory

class ProductListViewTestCase(TestCase):
    def setUp(self):
        self.product1 = ProductFactory(name='Test Product 1', price=10.00)
        self.product2 = ProductFactory(name='Test Product 2', price=20.00)

    def test_product_list_view_status_code(self):
        response = self.client.get(reverse('product_list'))
        self.assertEqual(response.status_code, 200)

    def test_product_list_view_template(self):
        response = self.client.get(reverse('product_list'))
        self.assertTemplateUsed(response, 'store/product_list.html')

    def test_product_list_view_context(self):
        response = self.client.get(reverse('product_list'))
        self.assertIn('products', response.context)
        self.assertEqual(len(response.context['products']), 2)
        self.assertEqual(response.context['products'][0].name, 'Test Product 1')
        self.assertEqual(response.context['products'][1].name, 'Test Product 2')

ついでに、テンプレート使われているかのテストも追加してみました。
store/tests.pyのviewのテストは削除してください。

そうしたらテストしてみてくださいね。

python manage.py test

3.11.3. モデルのテスト移行

viewのテストを分けたので、同じようにモデルも。
myproject/store/tests/test_models.pyにテストを移動

test.pyは削除します。ファイルを残しているとコマンドを実行してもエラーになる場合ある様です。

test_models.py
from django.test import TestCase

from store.factories import OrderFactory, ProductFactory


class ProductTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)

    def test_product_creation(self):
        self.assertEqual(self.product.name, 'Test Product')
        self.assertEqual(self.product.price, 10.00)


class OrderTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.order = OrderFactory(product=self.product, quantity=2)

    def test_order_creation(self):
        self.assertEqual(self.order.product.name, 'Test Product')
        self.assertEqual(self.order.quantity, 2)

    def test_order_total_price(self):
        self.assertEqual(self.order.total_price(), 20.00)

3.12.4. テンプレートのテスト項目

テンプレートのテストでは、以下の項目をテストすることにします。

  1. テンプレートの使用確認:

    • ビューが正しいテンプレートを使用しているかどうかを確認
  2. テンプレートの内容確認:

    • テンプレートに表示されるはずのコンテンツが含まれているかどうかを確認
    • つまり動的に生成したhtmlに特定のテキストがふくまれるかってこと

3.12.5. テンプレートのテスト例

store/tests/test_templates.py

from django.test import TestCase
from django.urls import reverse
from ..factories import ProductFactory

class ProductTemplateTestCase(TestCase):
    def setUp(self):
        self.product1 = ProductFactory(name='Test Product 1', price=10.00)
        self.product2 = ProductFactory(name='Test Product 2', price=20.00)

    def test_product_list_template(self):
        response = self.client.get(reverse('product_list'))
        self.assertTemplateUsed(response, 'store/product_list.html')
        self.assertContains(response, 'Test Product 1')
        self.assertContains(response, 'Test Product 2')

assertContainsはテンプレートのテストで使用するテストです。Test Product 1という文字列入ってる?をテストしてます。

3.12.6. ちょっとまとめ

  • ビューのテスト:

    • ステータスコードの確認(例: self.assertEqual(response.status_code, 200)
      • 200だけでなくログインしないと入れないページなどの場合は400番台のエラー返すかのテストを最低限書いておくといいと思います。
    • テンプレートの使用確認(例: self.assertTemplateUsed(response, 'store/product_list.html')
    • コンテキストデータの確認(例: self.assertIn('products', response.context)
  • テンプレートのテスト:

    • テンプレートの使用確認(例: self.assertTemplateUsed(response, 'store/product_list.html')
    • テンプレートの内容確認(例: self.assertContains(response, 'Test Product 1')

ビューのテストでは、ビューが正しいテンプレートを使用し、正しいデータをテンプレートに渡しているかを確認します。一方、テンプレートのテストでは、テンプレートに期待されるコンテンツが正しくレンダリングされているかを確認します。

ここまで書いておいて、申し訳ないですが、私はAPIのテストばかり書いていてtemplateのテストはメールのテンプレートくらいしか書いたことないのでおかしかったら教えてください😇

補足: 単体テストの自動検出について

Djangoのpython manage.py testコマンドを使用すると、Djangoプロジェクト内の単体テストが自動的に検出され、実行されます。このプロセスには、いくつかのルールや命名規則があります。以下にそれらについて詳しく説明します。

1. テストの検出ルール

Djangoは、以下のルールに従ってテストを検出します。

  1. tests.pyファイル:

    • アプリケーションディレクトリにあるtests.pyファイルは自動的に検出されます。このファイル内のすべてのunittest.TestCaseのサブクラスが実行されます。
  2. testsディレクトリ:

    • アプリケーションディレクトリにあるtestsディレクトリも自動的に検出されます。このディレクトリ内のすべてのモジュール(ファイル)は、名前がtestで始まるか、または_testで終わる場合に検出されます。

2. テストケースの命名規則

Djangoのテストフレームワークは、Pythonの標準ライブラリであるunittestをベースにしています。unittestには特定の命名規則があります。

  1. テストクラス:

    • テストクラスは、unittest.TestCaseを継承する必要があります。
    • クラス名は通常、Testで始めるのが一般的です。例:TestProductProductTestCase
    • お前やってないやん!!ってツッコミ入ると思いますがTestつけるの推奨です🙇
  2. テストメソッド:

    • テストメソッドの名前はtestで始める必要があります。これにより、テストランナーは、それがテストなんだねーみたいに認識してくれます。例:test_product_creationtest_order_total_price
    • こっちはメソッド名にtestつけないとテスト実行してくれないので必ずつけましょう

4.1. 商品詳細ページの追加

ページが一つしかないのも寂しいので商品の詳細ページ作ります。
必須でないですが、branch切っておきます。

git checkout -b create_detail

4.1.1 概要

商品詳細ページを追加することで、ユーザーが商品の詳細情報を閲覧できるようにします。これには、ビュー、テンプレート、URLの設定、および単体テストを作成していきます。

4.2. モデルの確認

まず、Productモデルに追加します。

store/models.py
from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    description = models.TextField(null=True, blank=True)

    def __str__(self):
        return self.name

説明を表示したかったのでmodelに追加してDBへ保存できるようにしました。

python manage.py makemigrations
python manage.py migrate

4.3. ビューの追加

次に、商品詳細ページを表示するためのビューを追加します。product_detailビューでは、指定されたIDのProductオブジェクトを取得し、それをテンプレートに渡します。

store/views.py
from django.shortcuts import render, get_object_or_404
from .models import Product

def product_detail(request, pk):
    product = get_object_or_404(Product, pk=pk)
    return render(request, 'store/product_detail.html', {'product': product})

4.4. テンプレートの作成

商品詳細情報を表示するためのテンプレートを作成します。store/templates/store/product_detail.htmlに以下のような内容を記述します。
英語である必要はないです。ただし、後で単体テストのassertの部分を適時修正してください。

store/templates/store/product_detail.html
<!DOCTYPE html>
<html>
<head>
    <title>{{ product.name }}</title>
</head>
<body>
    <h1>{{ product.name }}</h1>
    <p>Price: ${{ product.price }}</p>
    {% if product.description %}
        <p>{{ product.description }}</p>
    {% else %}
        <p>No description available.</p>
    {% endif %}
    <a href="{% url 'product_list' %}">Back to product list</a>
</body>
</html>

4.5. URLの設定

store/urls.pyに、商品詳細ページへのURLパターンを追加します。

store/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.product_list, name='product_list'),
    path('product/<int:pk>/', views.product_detail, name='product_detail'),
]

リンクがないので、直接アクセス。

http://127.0.0.1:8000/store/product/1/

説明を追加していなかったので表示されていませんね。

スクリーンショット 2024-06-30 3.33.55.png

更新機能をまだ実装していないので管理画面で変更してください。

http://127.0.0.1:8000/admin/

スクリーンショット 2024-06-30 3.36.50.png

4.6. 単体テストの追加

商品詳細ページが正しく動作することを確認するための単体テストを作成します。

テストを追加します。

store/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from ..models import Product
from ..factories import ProductFactory

class ProductDetailViewTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00, description='This is a test product.')

    def test_product_detail_view_status_code(self):
        response = self.client.get(reverse('product_detail', args=[self.product.pk]))
        self.assertEqual(response.status_code, 200)

    def test_product_detail_view_template(self):
        response = self.client.get(reverse('product_detail', args=[self.product.pk]))
        self.assertTemplateUsed(response, 'store/product_detail.html')

    def test_product_detail_view_context(self):
        response = self.client.get(reverse('product_detail', args=[self.product.pk]))
        self.assertIn('product', response.context)
        self.assertEqual(response.context['product'].name, 'Test Product')

    def test_product_detail_view_nonexistent_product(self):
        response = self.client.get(reverse('product_detail', args=[999]))
        self.assertEqual(response.status_code, 404)

詳細画面のテストの追加と、テスト名があまり良くないのでProductListに修正しました。

store/tests/test_templates.py
from django.test import TestCase
from django.urls import reverse
from ..factories import ProductFactory


class ProductList(TestCase):
    def setUp(self):
        self.product1 = ProductFactory(name='Test Product 1', price=10.00)
        self.product2 = ProductFactory(name='Test Product 2', price=20.00)

    def test_product_list_template(self):
        response = self.client.get(reverse('product_list'))
        self.assertTemplateUsed(response, 'store/product_list.html')
        self.assertContains(response, 'Test Product 1')
        self.assertContains(response, 'Test Product 2')


class ProductDetail(TestCase):
    def setUp(self):
        self.product = ProductFactory(
            name='Test Product', price=10.00, description='This is a test product.')

    def test_product_detail_template(self):
        response = self.client.get(
            reverse('product_detail', args=[self.product.pk]))
        self.assertTemplateUsed(response, 'store/product_detail.html')
        self.assertContains(response, 'Test Product')
        self.assertContains(response, 'Price: $10.00')
        self.assertContains(response, 'This is a test product.')

4.6.1. テスト実行

python manage.py test
Found 13 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.............
----------------------------------------------------------------------
Ran 13 tests in 0.015s

OK
Destroying test database for alias 'default'...

assertEqualとかの第二引数を変更して失敗することも確認してみてください。
失敗するはずなのに失敗しないと意味ないテスト書いてるってことです。

4.7. テンプレートの修正

まず、商品一覧テンプレートproduct_list.htmlを修正して、商品名をクリックすると詳細ページに遷移するリンクを追加します。

store/templates/store/product_list.html
<!DOCTYPE html>
<html>
<head>
    <title>Product List</title>
</head>
<body>
    <h1>Product List</h1>
    <ul>
        {% for product in products %}
            <li>
                <a href="{% url 'product_detail' product.pk %}">{{ product.name }}</a> - ¥{{ product.price }}
            </li>
        {% endfor %}
    </ul>
</body>
</html>

http://127.0.0.1:8000/store/

スクリーンショット 2024-06-30 3.33.13.png

商品詳細ページが追加され、単体テストも含めて実装しました。ユーザーは商品リストから商品を選択して、詳細情報を閲覧できるようになります。また、単体テストを作成することで、ビューが正しく動作することを確認できます。

ブランチを切っていたらmainにマージするなどは各自行ってくださいね。

5. カート機能の追加

ECサイトっぽくしたいのでカート追加機能をつけます!
ブランチ切るのは任意です。当然やんなくてもOK

git checkout -b add_cart

5.1. 概要

カート機能を追加することで、ユーザーが商品をカートに追加し、カート内の商品を確認・管理できるようにします。この機能には、モデル、ビュー、テンプレート、URLの設定、および単体テストを書きますね。

5.2. モデルの作成

まず、DBにカート情報を保存できるように作れていないので、CartおよびCartItemモデルを追加します。Cartは複数のProductを保持し、CartItemは各Productの数量を管理します。

store/models.py
.
.
.

class Cart(models.Model):
    products = models.ManyToManyField(Product, through='CartItem')

    def __str__(self):
        return f"Cart {self.id}"

class CartItem(models.Model):
    cart = models.ForeignKey(Cart, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField(default=1)

    def __str__(self):
        return f"{self.product.name} in cart {self.cart.id}"

いつも通り、マイグレーションファイルとmigrateやってテーブル追加してください。

$ python manage.py makemigrations
Migrations for 'store':
  store/migrations/0003_cart_cartitem_cart_products.py
    - Create model Cart
    - Create model CartItem
    - Add field products to cart
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, store
Running migrations:
  Applying store.0003_cart_cartitem_cart_products... OK

5.3. ビューの追加

これまでは、関数ベースビューでしたが、 今回、カート機能を実装するために、以下のクラスベースビューを作成します。

  1. AddToCartView
  2. CartDetailView

5.3.1. AddToCartView

AddToCartViewは、ユーザーが特定の商品をカートに追加するためのビューです。このビューはViewクラスを継承しています。
(postで登録したほうがいいですが、とりあえずgetでやります。)

sessionについては補足も書きましたので、興味あったらみてください。

store/views.py
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from .models import Product, Cart, CartItem

class AddToCartView(View):
    def get(self, request, pk):
        # セッションからカートIDを取得
        cart_id = request.session.get('cart_id')
        
        # カートIDがない場合、新しいカートを作成
        if not cart_id:
            cart = Cart.objects.create()
            request.session['cart_id'] = cart.id
        else:
            cart = get_object_or_404(Cart, id=cart_id)

        # 指定された商品IDから商品を取得
        product = get_object_or_404(Product, pk=pk)
        
        # カートアイテムを取得または新規作成
        cart_item, created = CartItem.objects.get_or_create(cart=cart, product=product)
        if not created:
            # 既にカートにある場合は数量を増やす
            cart_item.quantity += 1
            cart_item.save()
        
        # カート詳細ページにリダイレクト
        return redirect('cart_detail')

動作の流れ:

  1. セッションからカートIDを取得:

    • request.session.get('cart_id')を使ってセッションからカートIDを取得します。
    • カートIDが存在しない場合、新しいカートを作成し、そのIDをセッションに保存します。
  2. 商品を取得:

    • get_object_or_404(Product, pk=pk)を使って指定された商品IDから商品を取得します。
  3. カートアイテムを取得または新規作成:

    • CartItem.objects.get_or_create(cart=cart, product=product)を使って、カート内の商品アイテムを取得または新規作成します。
    • 既にカートに商品がある場合は、その数量を増やします。
  4. カート詳細ページにリダイレクト:

    • redirect('cart_detail')を使ってカート詳細ページにリダイレクトします。

5.3.2. CartDetailView

CartDetailViewは、カートの内容を表示するためのビューです。このビューはDetailViewクラスを継承しています。

store/views.py

from django.views.generic.detail import DetailView
from .models import Cart

class CartDetailView(DetailView):
    model = Cart
    template_name = 'store/cart_detail.html'
    context_object_name = 'cart'

    def get_object(self, queryset=None):
        # セッションからカートIDを取得
        cart_id = self.request.session.get('cart_id')
        if cart_id:
            # カートIDが存在する場合はカートを取得
            return get_object_or_404(Cart, id=cart_id)
        else:
            # カートIDが存在しない場合はNoneを返す
            return None

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # カートが空かどうかを判断し、コンテキストに追加
        context['empty'] = self.object is None
        return context

動作の流れ:

  1. モデル、テンプレート、コンテキストの設定:

    • model属性でCartモデルを指定。
    • template_name属性で使用するテンプレートを指定。
    • context_object_name属性でコンテキストに渡すオブジェクトの名前を指定。
  2. カートオブジェクトの取得:

    • get_objectメソッドをオーバーライドし、セッションからカートIDを取得します。
    • カートIDが存在する場合は、get_object_or_404を使ってカートを取得します。
    • カートIDが存在しない場合は、Noneを返します。
  3. コンテキストデータの設定:

    • get_context_dataメソッドをオーバーライドし、カートが空かどうかを判断してemptyフラグをコンテキストに追加します。

5.4. テンプレートの説明

5.4.1. 商品一覧テンプレート

商品一覧テンプレートでは、各商品の名前と価格を表示し、商品名のリンクをクリックすると詳細ページに遷移します。また、「Add to cart」リンクをクリックすると商品がカートに追加されます。

store/templates/store/product_list.html
<!DOCTYPE html>
<html>
<head>
    <title>Product List</title>
</head>
<body>
    <h1>Product List</h1>
    <ul>
        {% for product in products %}
            <li>
                <a href="{% url 'product_detail' product.pk %}">{{ product.name }}</a> - ¥{{ product.price }}
                <a href="{% url 'add_to_cart' product.pk %}">Add to cart</a>
            </li>
        {% endfor %}
    </ul>
</body>
</html>

5.4.2.カート詳細テンプレート

カート詳細テンプレートでは、カート内の各商品の名前と数量を表示します。カートが空の場合は、その旨を表示します。

store/templates/store/cart_detail.html
<!DOCTYPE html>
<html>
<head>
    <title>Cart Detail</title>
</head>
<body>
    <h1>Cart Detail</h1>
    {% if cart and cart.cartitem_set.all %}
        <ul>
            {% for item in cart.cartitem_set.all %}
                <li>{{ item.product.name }} - Quantity: {{ item.quantity }}</li>
            {% endfor %}
        </ul>
    {% else %}
        <p>Your cart is empty.</p>
    {% endif %}
    <a href="{% url 'product_list' %}">Continue shopping</a>
</body>
</html>

5.4.3. URL設定について

解説する必要ないかもですが、一応。URL設定では、商品詳細ページとカートに関連するURLパターンを追加してます。

store/urls.py

from django.urls import path
from . import views

urlpatterns = [
    .
    .
    .
    path('cart/add/<int:pk>/', views.AddToCartView.as_view(), name='add_to_cart'),
    path('cart/', views.CartDetailView.as_view(), name='cart_detail'),
]

これにより、ユーザーは商品をカートに追加し、カートの内容を確認することができます。

2回カートに入れてみました。
スクリーンショット 2024-06-30 17.21.35.png

スクリーンショット 2024-06-30 17.14.29.png

5.5. 単体テスト追加

5.5.1. ファクトリの作成

store/factories.pyファイルにCartおよびCartItemのファクトリを追加します。

store/factories.py
import factory
from .models import Product, Cart, CartItem

class ProductFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Product

    name = factory.Faker('word')
    price = factory.Faker('pydecimal', left_digits=5, right_digits=2, positive=True)
    description = factory.Faker('sentence')

class CartFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Cart

    @factory.post_generation
    def products(self, create, extracted, **kwargs):
        if not create:
            return

        if extracted:
            for product in extracted:
                self.products.add(product)

class CartItemFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = CartItem

    cart = factory.SubFactory(CartFactory)
    product = factory.SubFactory(ProductFactory)
    quantity = factory.Faker('random_int', min=1, max=10)

カート機能が正しく動作することを確認するための単体テストを作成します。

store/tests/test_views.py
class CartViewTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.cart = CartFactory()
        # セッションにカートIDを設定
        session = self.client.session
        session['cart_id'] = self.cart.id
        session.save()

    def test_add_to_cart(self):
        response = self.client.get(
            reverse('add_to_cart', args=[self.product.pk]))
        self.assertRedirects(response, reverse('cart_detail'))
        cart = Cart.objects.get(pk=self.client.session['cart_id'])
        self.assertEqual(cart.products.count(), 1)
        self.assertEqual(cart.cartitem_set.first().product, self.product)

    def test_cart_detail_view(self):
        CartItemFactory(cart=self.cart, product=self.product, quantity=2)
        response = self.client.get(reverse('cart_detail'))
        # self.assertEqual(response.status_code, 200)
        print(response.content.decode())  # レスポンス内容をデバッグ出力したいとき用に残した
        self.assertContains(response, 'Test Product')
        self.assertContains(response, 'Quantity: 2')

今回、modelのテストは追加しません。(これは決めの問題なところもあると思うのですが)特に重要なロジックを入れている訳でもなく、Djangoの機能をそのまま使っているだけなのでいらないと思います。

これで、クラスベースビューを使用したカート機能の実装が完了しました。画面表示できるかと、機能が動作するか、単体テストでエラー出ないか試してください。

python manage.py test

6. カート詳細へのリンクとカートからアイテムを削除する機能

とりあえずカートに追加できる機能を作りましたが、削除できないと意味ないですね。
あと、カートに移動できないのでリンクつけます。

  • カート詳細に行くリンクを追加
  • カートからアイテムを削除する機能を実装

6.1. カート詳細へのリンクの追加

商品一覧テンプレートにカート詳細へのリンクを追加します。

6.1.1. 商品一覧テンプレートの修正

store/templates/store/product_list.html
<!DOCTYPE html>
<html>
<head>
    <title>Product List</title>
</head>
<body>
    <h1>Product List</h1>
    <ul>
        {% for product in products %}
            <li>
                <a href="{% url 'product_detail' product.pk %}">{{ product.name }}</a> - ¥{{ product.price }}
                <a href="{% url 'add_to_cart' product.pk %}">Add to cart</a>
            </li>
        {% endfor %}
    </ul>
    <a href="{% url 'cart_detail' %}">View Cart</a>
</body>
</html>

6.2. カートからアイテムを削除する機能の追加

カートからアイテムを削除するビューとテンプレートの変更を行います。

6.2.1. ビューの追加

カートからアイテムを削除するためのビューを追加します。
いろんなパターンを見たほうが良いと思うので、追加はgetでしたが削除に関してはpostで削除してみます。(チグハグ感がありますが)

store/views.py
.
.
.
class RemoveFromCartView(View):
    def post(self, request, pk):
        cart_id = request.session.get('cart_id')
        cart = get_object_or_404(Cart, id=cart_id)
        product = get_object_or_404(Product, pk=pk)
        cart_item = get_object_or_404(CartItem, cart=cart, product=product)
        cart_item.delete()
        return redirect('cart_detail')

6.2.2. URLの追加

URLパターンにアイテム削除のエンドポイントを追加します。

store/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.product_list, name='product_list'),
    path('product/<int:pk>/', views.product_detail, name='product_detail'),
    path('cart/add/<int:pk>/', views.AddToCartView.as_view(), name='add_to_cart'),
    path('cart/remove/<int:pk>/', views.RemoveFromCartView.as_view(),
         name='remove_from_cart'),
    path('cart/', views.CartDetailView.as_view(), name='cart_detail'),
]

6.2.3. カート詳細テンプレートの修正

カート詳細テンプレートにアイテム削除のリンクを追加します。

store/templates/store/cart_detail.html
<!DOCTYPE html>
<html>
<head>
    <title>Cart Detail</title>
</head>
<body>
    <h1>Cart Detail</h1>
    {% if cart and cart.cartitem_set.all %}
        <ul>
            {% for item in cart.cartitem_set.all %}
                <li>
                    {{ item.product.name }} - Quantity: {{ item.quantity }}
                    <form action="{% url 'remove_from_cart' item.product.pk %}" method="post" style="display:inline;">
                        {% csrf_token %}
                        <button type="submit">Remove</button>
                    </form>
                </li>
            {% endfor %}
        </ul>
    {% else %}
        <p>Your cart is empty.</p>
    {% endif %}
    <a href="{% url 'product_list' %}">Continue shopping</a>
</body>
</html>

削除するボタンを追加しました。

スクリーンショット 2024-06-30 19.10.30.png

数量を一つ減らす機能も欲しいと思いましたが、単体テストが目的の記事なのでパスします。

6.3. テストの追加

カートからアイテムを削除する機能のテストを追加します。

store/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from ..models import Product, Cart, CartItem
from ..factories import ProductFactory, CartFactory, CartItemFactory

class CartViewTestCase(TestCase):
    def setUp(self):
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.cart = CartFactory()
        session = self.client.session
        session['cart_id'] = self.cart.id
        session.save()

    def test_add_to_cart(self):
        response = self.client.get(reverse('add_to_cart', args=[self.product.pk]))
        self.assertRedirects(response, reverse('cart_detail'))
        cart = Cart.objects.get(pk=self.client.session['cart_id'])
        self.assertEqual(cart.products.count(), 1)
        self.assertEqual(cart.cartitem_set.first().product, self.product)

    def test_cart_detail_view(self):
        CartItemFactory(cart=self.cart, product=self.product, quantity=2)
        response = self.client.get(reverse('cart_detail'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, 'Test Product')
        self.assertContains(response, 'Quantity: 2')

    def test_remove_from_cart(self):
        cart_item = CartItemFactory(cart=self.cart, product=self.product, quantity=2)
        response = self.client.post(reverse('remove_from_cart', args=[self.product.pk]))
        self.assertRedirects(response, reverse('cart_detail'))
        cart = Cart.objects.get(pk=self.client.session['cart_id'])
        self.assertEqual(cart.cartitem_set.count(), 0)

追加したテストを試してください。

python manage.py test store.tests.test_views.CartViewTestCase.test_remove_from_cart

6.4. カート機能のまとめ

これで、以下の機能を追加しました:

  • 商品一覧にカート詳細へのリンクを追加
  • カートからアイテムを削除する機能を追加
  • カート詳細テンプレートを修正して削除リンクを追加
  • テストを追加して新しい機能を確認

補足: セッションについて

self.request.session は、Djangoのセッションフレームワークを使用して、ユーザーごとに状態を保持するための方法です。セッションは、ユーザーがウェブサイトを訪れている間に保持されるデータを保存するための仕組みです。セッションデータはサーバー側に保存され、クライアント側にはセッションIDのみがCookieとして保存されます。

セッションの概要

Djangoのセッションフレームワークを使用すると、以下のことができます:

  • ユーザーのログイン状態を保持する
  • ショッピングカートの内容を保存する
  • 一時的なメッセージやデータを保存する

セッションの使用方法

1. セッションの設定

まず、settings.pyでセッションの設定を確認します。デフォルトでは、Djangoはデータベースを使用してセッションを保存します。
記述しないでOKです。

myproject/settings.py
# セッションエンジンの設定(デフォルトはデータベースバックエンド)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'

2. セッションの使用例

セッションを使用するために、requestオブジェクトのsession属性を操作します。

セッションにデータを保存する

def my_view(request):
    # 'cart_id'キーにデータを保存
    request.session['cart_id'] = 1234

セッションからデータを取得する

def my_view(request):
    # 'cart_id'キーからデータを取得
    cart_id = request.session.get('cart_id', None)
    if cart_id:
        # カートIDが存在する場合の処理
        pass
    else:
        # カートIDが存在しない場合の処理
        pass

セッションからデータを削除する

def my_view(request):
    # 'cart_id'キーを削除
    try:
        del request.session['cart_id']
    except KeyError:
        pass

クラスベースビューでの使用例

クラスベースビューでは、self.request.sessionを使用してセッションデータを操作します。以下は、カート機能のクラスベースビューでのセッション使用例です。

商品をカートに追加するビュー

store/views.py

from django.shortcuts import get_object_or_404, redirect
from django.views import View
from .models import Product, Cart, CartItem

class AddToCartView(View):
    def get(self, request, pk):
        # セッションからカートIDを取得
        cart_id = self.request.session.get('cart_id')
        
        # カートIDがない場合、新しいカートを作成
        if not cart_id:
            cart = Cart.objects.create()
            self.request.session['cart_id'] = cart.id
        else:
            cart = get_object_or_404(Cart, id=cart_id)

        # 指定された商品IDから商品を取得
        product = get_object_or_404(Product, pk=pk)
        
        # カートアイテムを取得または新規作成
        cart_item, created = CartItem.objects.get_or_create(cart=cart, product=product)
        if not created:
            # 既にカートにある場合は数量を増やす
            cart_item.quantity += 1
            cart_item.save()
        
        # カート詳細ページにリダイレクト
        return redirect('cart_detail')

カート詳細ビュー

store/views.py

from django.views.generic.detail import DetailView
from .models import Cart

class CartDetailView(DetailView):
    model = Cart
    template_name = 'store/cart_detail.html'
    context_object_name = 'cart'

    def get_object(self, queryset=None):
        # セッションからカートIDを取得
        cart_id = self.request.session.get('cart_id')
        if cart_id:
            # カートIDが存在する場合はカートを取得
            return get_object_or_404(Cart, id=cart_id)
        else:
            # カートIDが存在しない場合はNoneを返す
            return None

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # カートが空かどうかを判断し、コンテキストに追加
        context['empty'] = self.object is None
        return context

self.request.sessionを使用することで、ユーザーごとに一時的なデータを保存できます。これにより、ユーザーが商品をカートに追加したり、ログイン状態を保持したりすることが可能になります。セッションは、ユーザーがサイトを訪れている間にデータを保持する便利な方法です。

補足: Factoryをカスタマイズ

@factory.post_generation とかいう意味わからないデコレータがありますね。これはfactory_boyで使用されるデコレーターで、オブジェクトが生成された後に追加の処理を実行するためのものです。これにより、生成されたオブジェクトに対してさらにカスタマイズや関連オブジェクトの追加を行うことができます。

使用例と解説

@factory.post_generation の使用例を見てみます。

例: CartFactory の場合

CartFactoryproducts フィールドを @factory.post_generation デコレーターを使ってカスタマイズしています。

class CartFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Cart

    @factory.post_generation
    def products(self, create, extracted, **kwargs):
        if not create:
            return

        if extracted:
            for product in extracted:
                self.products.add(product)

この例では、CartFactory が生成された後に products フィールドに対して追加の処理を行っています。

  • create: この引数は、オブジェクトがデータベースに保存されたかどうかを示します。True なら保存されています。
  • extracted: ファクトリの呼び出し時に追加で渡された値です。この場合、products フィールドに渡された値がここに含まれます。

動作の流れ:

  1. オブジェクト生成後の処理:

    • オブジェクトが生成されデータベースに保存された後に、@factory.post_generation でデコレートされたメソッドが呼び出されます。
  2. extracted の処理:

    • ファクトリの呼び出し時に products フィールドに追加で渡された値(リストなど)が extracted 引数として渡されます。
    • extracted が渡された場合、その中の各 productself.products に追加します。

難しそうに見えますが、「ManyToMany」のデータを作るときにSubFactoryでは作れないからこんなふうに書いているわけです。

ManyToManyだけに限らず、生成したときに他にも作りたいデータというのがどうしてもあります。そんなときにつかえるわけです。

ファクトリの使用例:

# 例: CartFactoryを使ってカートを生成し、特定の商品を追加
product1 = ProductFactory(name='Product 1')
product2 = ProductFactory(name='Product 2')
cart = CartFactory(products=[product1, product2])

この例では、CartFactory を使ってカートを生成すると同時に、product1product2 がカートに追加されます。

@factory.post_generation は、ファクトリで生成されたオブジェクトに対して追加のカスタマイズを行うための強力なツールです。これを使用することで、生成されたオブジェクトに対してさらに関連オブジェクトを追加したり、特定のフィールドをカスタマイズしたりすることができます。これにより、テストデータの作成が柔軟かつ簡単になります。

:pushpin: 7. Mockを使用したテスト

Mockを使ったテストを書きたいので、注文機能を追加しようと思います。

Mockってどんなときにつかうのかをざっくりいうと、

  1. 外部APIとの通信:

    • 注文処理で外部の支払いゲートウェイや配送サービスと通信する場合、これらの通信をMockでシミュレートすることができます。
  2. メール送信:

    • 注文が完了した際にユーザーに確認メールを送信する場合、メール送信機能をMockでテストすることができます。
  3. データベースへの保存:

    • 注文データの保存処理をMockでシミュレートし、データベースに依存しないテストを実行することができます。

7.1. 注意点と、どういうとき使うのか?

  • 何でもかんでもMockにするのは良くない
  • 一連の処理の中でそれぞれの機能をそれぞれで単体テストするとよい
    • 例えば、カートの数を減らして更新、注文のデータを保存、その後メールを送信するなら、メールの送信機能は独立の機能で切り出せるのでMockが良い。メール送信までテストすると統合テストになりそう
  • 処理の時間がかかる場合。欲しいのは計算した後の値なので固定値のMockしてしまえば実行時間削減できる。pandasで行列計算するとかしたら大変ですよね。メモリも食うし
  • 外部のAPIを実行するとき。外部のAPIは単体テストの範囲外で、これは統合テストになる

ここでは、注文処理の際にメール送信をMockでテストする例を実装します。

7.2. 注文機能の追加

7.2.1. モデルの作成

注文と注文アイテムのモデルを追加します。
Orderモデルを以前に作ってしまっていましたのでPlaceOrder。記事書きながら作ってたのであまり良い作りでないですがご了承ください💦

store/models.py
from django.db import models
from django.contrib.auth.models import User

.
.
.

class PlaceOrder(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"Order {self.id}"

class OrderItem(models.Model):
    order = models.ForeignKey(PlaceOrder, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField()

    def __str__(self):
        return f"{self.product.name} in order {self.order.id}"

7.2.2. マイグレーションの作成

モデルの変更を反映するためにマイグレーションを作成します。

python manage.py makemigrations
python manage.py migrate

7.2.3. ビューの作成

PlaceOrderView を作成し、注文処理時に PlaceOrder および OrderItem を作成するようにします。

store/views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.views import View
from .models import Product, Cart, CartItem, PlaceOrder, OrderItem
from django.core.mail import send_mail
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator

class PlaceOrderView(View):
    @method_decorator(login_required)
    def post(self, request):
        cart_id = request.session.get('cart_id')
        # セッションからcart_id取れないなら処理させたくない。
        if not cart_id:
            return redirect('cart_detail')
        # DBに保存されたカート情報を取得。発注のデータを作成
        cart = Cart.objects.get(id=cart_id)
        order = PlaceOrder.objects.create(user=request.user)

        for item in cart.cartitem_set.all():
            OrderItem.objects.create(
                order=order,
                product=item.product,
                quantity=item.quantity
            )

        send_mail(
            'Order Confirmation',
            f'Thank you for your order {request.user.username}!',
            'from@example.com',
            [request.user.email],
            fail_silently=False,
        )

        cart.cartitem_set.all().delete()
        return redirect('order_confirmation', pk=order.pk)
  • cartitem_setは逆参照です。cartは一つに、cartitemが複数結びつくのでこのように書きます。
  • カートに入った商品から、OrderItemを作成します。
  • その後メールを送信します。単純なメールを送るsend_mailを使います。
  • 最後にカートの中身を削除します。

色々ツッコミどころがあるコードですが、Mockのテストをするには十分なのでどんどんいきます!

7.3. メールの設定

settings.pyでメールバックエンドをコンソールバックエンドに設定します。これにより、送信されるメールがコンソールに出力されます。

myproject/settings.py
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

7.4. URLの追加

URLパターンに注文処理のエンドポイントを追加します。

store/urls.py
from django.urls import path
from . import views

urlpatterns = [
    .
    .
    .
    path('order/place/', views.PlaceOrderView.as_view(), name='place_order'),
    path('order/confirmation/<int:pk>/', views.OrderConfirmationView.as_view(), name='order_confirmation'),
]

7.5. テンプレートの修正

カート詳細テンプレートに注文ボタンを追加します。

store/templates/store/cart_detail.html

<!DOCTYPE html>
<html>
<head>
    <title>Cart Detail</title>
</head>
<body>
    <h1>Cart Detail</h1>
    {% if cart and cart.cartitem_set.all %}
        <ul>
            {% for item in cart.cartitem_set.all %}
                <li>
                    {{ item.product.name }} - Quantity: {{ item.quantity }}
                    <form action="{% url 'remove_from_cart' item.product.pk %}" method="post" style="display:inline;">
                        {% csrf_token %}
                        <button type="submit">Remove</button>
                    </form>
                </li>
            {% endfor %}
        </ul>
        <form action="{% url 'place_order' %}" method="post">
            {% csrf_token %}
            <button type="submit">Place Order</button>
        </form>
    {% else %}
        <p>Your cart is empty.</p>
    {% endif %}
    <a href="{% url 'product_list' %}">Continue shopping</a>
</body>
</html>

7.6. 注文確認ビューとテンプレート

注文確認ページを表示するためのビューとテンプレートを追加します。

store/views.py
class OrderConfirmationView(View):
    def get(self, request, pk):
        order = get_object_or_404(PlaceOrder, pk=pk)
        return render(request, 'store/order_confirmation.html', {'order': order})
store/templates/store/order_confirmation.html
<!DOCTYPE html>
<html>
<head>
    <title>Order Confirmation</title>
</head>
<body>
    <h1>Thank you for your order!</h1>
    <p>Your order number is {{ order.id }}.</p>
    <a href="{% url 'product_list' %}">Continue shopping</a>
</body>
</html>

7.6.1. 確認

Place order を押すと

スクリーンショット 2024-07-01 9.17.40.png

メールの内容がコンソールに表示されてます。

[01/Jul/2024 00:17:12] "GET /store/cart/ HTTP/1.1" 200 900
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: Order Confirmation
From: from@example.com
To: sample@sample.com
Date: Mon, 01 Jul 2024 00:17:15 -0000
Message-ID: <171979303531.92220.2415555569104255581@1.0.0.127.in-addr.arpa>

Thank you for your order user1!
-------------------------------------------------------------------------------
[01/Jul/2024 00:17:15] "POST /store/order/place/ HTTP/1.1" 302 0
[01/Jul/2024 00:17:15] "GET /store/order/confirmation/2/ HTTP/1.1" 200 216

良さげです。わかる方だけでいいのですが、DBの中身みれる方は各データが作成されていたり、削除されているのを確認してみてください。

7.7. テストの追加

注文処理のテストを追加し、メール送信をMockでテストします。

store/factories.py
from django.contrib.auth.models import User

class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Faker('user_name')
    email = factory.Faker('email')
    password = factory.PostGenerationMethodCall('set_password', 'password')
store/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch
from ..models import Product, Cart, CartItem, PlaceOrder, OrderItem
from ..factories import ProductFactory, CartFactory, CartItemFactory, UserFactory

class OrderViewTestCase(TestCase):
    def setUp(self):
        self.user = UserFactory(username='testuser', email='test@example.com')
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.cart = CartFactory()
        self.cart_item = CartItemFactory(cart=self.cart, product=self.product, quantity=2)
        session = self.client.session
        session['cart_id'] = self.cart.id
        session.save()

    @patch('store.views.send_mail')
    def test_place_order(self, mock_send_mail):
        self.client.force_login(self.user)
        response = self.client.post(reverse('place_order'))
        self.assertRedirects(response, reverse('order_confirmation', args=[PlaceOrder.objects.first().pk]))

        # Check order and order items creation
        self.assertEqual(PlaceOrder.objects.count(), 1)
        order = PlaceOrder.objects.first()
        self.assertEqual(order.user, self.user)
        self.assertEqual(OrderItem.objects.count(), 1)
        order_item = OrderItem.objects.first()
        self.assertEqual(order_item.product, self.product)
        self.assertEqual(order_item.quantity, 2)

        # Check if the send_mail method was called
        mock_send_mail.assert_called_once_with(
            'Order Confirmation',
            'Thank you for your order testuser!',
            'from@example.com',
            [self.user.email],
            fail_silently=False,
        )

これで PlaceOrder および OrderItem モデルに基づいた注文機能を追加し、メール送信のテストにMockを使用する例が実装されました。この実装では、注文処理を行い、メール送信機能をMockでテストしています。これにより、外部の依存関係を切り離してテストを行うことができます。

補足: PostGenerationMethodCall

factory.PostGenerationMethodCall は、factory_boy でオブジェクト生成後に特定のメソッドを呼び出すための方法です。これにより、オブジェクト生成後に追加の初期化処理を行うことができます。

使用方法

factory.PostGenerationMethodCall を使用すると、ファクトリでオブジェクトを生成した後に任意のメソッドを呼び出すことができます。今回は、パスワードフィールドの設定やその他のフィールドの初期化に使用しました。

Djangoのユーザーモデル(Djangoが生成するModel)でパスワードを設定する場合、set_password メソッドを呼び出してパスワードをハッシュ化する必要があります。この場合に factory.PostGenerationMethodCall を使用します。

UserFactory の例

store/factories.py
import factory
from django.contrib.auth.models import User

class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Faker('user_name')
    email = factory.Faker('email')
    password = factory.PostGenerationMethodCall('set_password', 'defaultpassword')
  • username: factory.Faker('user_name') を使用してランダムなユーザー名を生成します。
  • email: factory.Faker('email') を使用してランダムなメールアドレスを生成します。
  • password: factory.PostGenerationMethodCall を使用して、ユーザーオブジェクトが生成された後に set_password メソッドを呼び出し、'defaultpassword' をハッシュ化してパスワードとして設定します。

factory.PostGenerationMethodCall の構文は以下の通りです:

factory.PostGenerationMethodCall(method_name, *args, **kwargs)
  • method_name: オブジェクト生成後に呼び出すメソッドの名前。
  • args: メソッドに渡す位置引数。
  • kwargs: メソッドに渡すキーワード引数。

Userモデルでuserを作るときに実行が必要なuser.set_password('defaultpassword')をUserFactoryを呼び出すだけで自動で実行してくれるということです。

8. ログイン機能をつける

ログイン機能作っていなかったので追加します。Djangoはデフォルトでログイン機能を提供してくれているのでルール通りに作っていけば簡単に完成します。

8.1. 認証URLの設定確認

まず、Djangoの認証システムのURLが正しく設定されていることを確認します。urls.pyで認証関連のURLをインクルードしているか確認してください。

myproject/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/', include('django.contrib.auth.urls')),  # 追加
    path('store/', include('store.urls')),
]

これにより、Djangoのデフォルトの認証URLが設定され、/accounts/login/などのURLが有効になります。

8.2. テンプレートの作成

Djangoのデフォルトの認証テンプレートが存在しない場合、templates/registration/login.htmlを作成する必要があります。

templates/registration/login.html
<!DOCTYPE html>
<html>
<head>
    <title>Login</title>
</head>
<body>
    <h2>Login</h2>
    <form method="post">
        {% csrf_token %}
        {{ form.as_p }}
        <button type="submit">Login</button>
    </form>
</body>
</html>

http://127.0.0.1:8000/accounts/login/

スクリーンショット 2024-07-01 13.04.22.png

ただし、エラーが出ます。

accounts/profile/がないと出ます。

このエラーは、ログイン後のリダイレクト先として/accounts/profile/が指定されているが、このURLが設定されていないために発生しています。デフォルトでは、Djangoはログイン後にユーザーのプロファイルページにリダイレクトしますが、そのURLが定義されていないため404エラーが発生します。

これを修正するには、ログイン後のリダイレクト先を設定するか、/accounts/profile/に対応するビューを作成する必要があります。

8.2.1. 方法1: ログイン後のリダイレクト先を設定する

ログイン後にユーザーを任意のページにリダイレクトするように設定することができます。例えば、ユーザーをホームページやダッシュボードページにリダイレクトするように設定できます。

myproject/settings.py

LOGIN_REDIRECT_URL = '/'  # ログイン後にリダイレクトするURLを任意で指定
LOGOUT_REDIRECT_URL = '/'  # ログアウト後にリダイレクトするURLを任意で指定

この設定により、上記のように設定すればユーザーはログイン後にホームページにリダイレクトされます。

8.2.2. 方法2: /accounts/profile/に対応するビューを作成する

ユーザーのプロフのページを作成し、/accounts/profile/に対応するビューを設定します。

上記のどちらかが必要です。せっかくなので、方法2をやってみます。

8.3. ビューの作成

store/views.py
from django.shortcuts import render
from django.contrib.auth.decorators import login_required

@login_required
def profile(request):
    return render(request, 'store/profile.html')

8.4. URLの設定

urls.pyでプロファイルビューへのURLパターンを追加します。

myproject/urls.py
from django.contrib import admin
from django.urls import include, path

from store.views import profile

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/', include('django.contrib.auth.urls')),
    path('accounts/profile/', profile, name='profile'),
    path('store/', include('store.urls')),
]

8.5. テンプレートの作成

プロファイルページのテンプレートを作成します。

store/templates/store/profile.html
<!DOCTYPE html>
<html>
<head>
    <title>Profile</title>
</head>
<body>
    <h1>Profile</h1>
    <p>Welcome, {{ user.username }}!</p>
    <form action="{% url 'logout' %}" method="post">
        {% csrf_token %}
        <button type="submit">Logout</button>
    </form>
</body>
</html>

ログイン後にリダイレクトされるページを設定するか、プロファイルページを作成することで、404エラーが解決され流はずです。
ログイン後にユーザーがリダイレクトされる先を適切に設定し、再度実行してみてください。

8.6. テストコード追加

テストコードでログインが要求されるURLが正しく設定されているか確認します。

store/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch
from django.contrib.auth.models import User
from ..models import Product, Cart, CartItem, PlaceOrder, OrderItem
from ..factories import ProductFactory, CartFactory, CartItemFactory, UserFactory

class OrderViewTestCase(TestCase):
    def setUp(self):
        self.user = UserFactory(username='testuser', email='test@example.com')
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.cart = CartFactory()
        self.cart_item = CartItemFactory(cart=self.cart, product=self.product, quantity=2)
        session = self.client.session
        session['cart_id'] = self.cart.id
        session.save()

    @patch('store.views.send_mail')
    def test_place_order(self, mock_send_mail):
        self.client.force_login(self.user)
        response = self.client.post(reverse('place_order'))
        self.assertRedirects(response, reverse('order_confirmation', args=[PlaceOrder.objects.first().pk]))

        # Check order and order items creation
        self.assertEqual(PlaceOrder.objects.count(), 1)
        order = PlaceOrder.objects.first()
        self.assertEqual(order.user, self.user)
        self.assertEqual(OrderItem.objects.count(), 1)
        order_item = OrderItem.objects.first()
        self.assertEqual(order_item.product, self.product)
        self.assertEqual(order_item.quantity, 2)

        # Check if the send_mail method was called
        mock_send_mail.assert_called_once_with(
            'Order Confirmation',
            'Thank you for your order testuser!',
            'from@example.com',
            [self.user.email],
            fail_silently=False,
        )

    def test_place_order_not_logged_in(self):
        response = self.client.post(reverse('place_order'))
        login_url = reverse('login')
        self.assertRedirects(response, f'{login_url}?next={reverse("place_order")}')
        self.assertEqual(PlaceOrder.objects.count(), 0)

ログイン、ログアウトのテストはDjangoの機能で提供されているものなので書きません。

8.7. 全体の流れ

このテストコードは、注文機能が正しく動作しているかを確認するための単体テストです。具体的には、注文が作成され、確認メールが送信されることを確認するテストと、ログインしていない場合に注文が拒否されることを確認するテストですね。

  • setUp メソッドでテストデータを準備
  • 注文処理をテストする test_place_order メソッド
  • ログインしていない場合の動作をテストする test_place_order_not_logged_in メソッド

8.8. 詳細解説

インポート部分

まず、必要なモジュールとクラスをインポートします。

from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch
from django.contrib.auth.models import User
from ..models import Product, Cart, CartItem, PlaceOrder, OrderItem
from ..factories import ProductFactory, CartFactory, CartItemFactory, UserFactory
  • TestCase: Djangoのテスト用のベースクラスです。
  • reverse: URLパターンの名前からURLを逆引きする関数です。
  • patch: モックオブジェクトを作成するための関数です。
  • User: Djangoのユーザーモデルです。
  • その他のインポートは、テストするモデルやファクトリです。

OrderViewTestCase クラス

テストクラス OrderViewTestCase を定義します。

class OrderViewTestCase(TestCase):

setUp メソッド

各テストメソッドの前に実行されるセットアップメソッドで、テストデータを準備します。

    def setUp(self):
        self.user = UserFactory(username='testuser', email='test@example.com')
        self.product = ProductFactory(name='Test Product', price=10.00)
        self.cart = CartFactory()
        self.cart_item = CartItemFactory(cart=self.cart, product=self.product, quantity=2)
        session = self.client.session
        session['cart_id'] = self.cart.id
        session.save()
  • UserFactory を使ってユーザーを作成します。
  • ProductFactory を使って商品を作成します。
  • CartFactory を使ってカートを作成します。
  • CartItemFactory を使ってカートアイテムを作成します。
  • セッションにカートIDを保存します。

test_place_order メソッド

注文が正常に処理され、確認メールが送信されることをテストします。

    @patch('store.views.send_mail')
    def test_place_order(self, mock_send_mail):
        self.client.force_login(self.user)
        response = self.client.post(reverse('place_order'))
        self.assertRedirects(response, reverse('order_confirmation', args=[PlaceOrder.objects.first().pk]))

        # Check order and order items creation
        self.assertEqual(PlaceOrder.objects.count(), 1)
        order = PlaceOrder.objects.first()
        self.assertEqual(order.user, self.user)
        self.assertEqual(OrderItem.objects.count(), 1)
        order_item = OrderItem.objects.first()
        self.assertEqual(order_item.product, self.product)
        self.assertEqual(order_item.quantity, 2)

        # Check if the send_mail method was called
        mock_send_mail.assert_called_once_with(
            'Order Confirmation',
            'Thank you for your order testuser!',
            'from@example.com',
            [self.user.email],
            fail_silently=False,
        )
  1. @patch('store.views.send_mail'): send_mail 関数をモックします。
  2. self.client.force_login(self.user): ユーザーをログインさせます。
  3. response = self.client.post(reverse('place_order')): place_order ビューにPOSTリクエストを送信します。
  4. self.assertRedirects(response, reverse('order_confirmation', args=[PlaceOrder.objects.first().pk])): リダイレクトが正しく行われることを確認します。
  5. self.assertEqual(PlaceOrder.objects.count(), 1): 注文が1件作成されていることを確認します。
  6. 注文の詳細を確認します(ユーザー、商品、数量)。
  7. mock_send_mail.assert_called_once_with(...): send_mail 関数が正しく呼び出されたことを確認します。

8.9. モックについて

  • 初めてのときは分かりにくいですが、mock_send_mailsend_mailの偽物として振る舞えるようになっています。mock_send_mail.return_value = Noneと書けば単純にsend_mailでメールが送る処理をしないでNoneの戻り値を返す偽物メソッドに上書きしてテストを実行してくれるようになる。
    • ついでに別にその場合はNoneでなくても1とかhogeでもいいです
  • ただし、今回はassert_called_once_withをつかう。名前の通り、1回だけsend_mailを呼ばれたのを確認するということ。

8.9.1. test_place_order_not_logged_in メソッド

ログインしていない場合に注文が拒否されることをテストします。

    def test_place_order_not_logged_in(self):
        response = self.client.post(reverse('place_order'))
        login_url = reverse('login')
        self.assertRedirects(response, f'{login_url}?next={reverse("place_order")}')
        self.assertEqual(PlaceOrder.objects.count(), 0)
  1. ログインしていない状態で place_order ビューにPOSTリクエストを送信
  2. ログインページにリダイレクトされることを確認
  3. 注文が作成されていないことを確認

注文機能の動作と認証のチェックが正しく行われていることを保証できるわけです。

  • ログインしたユーザーが注文を行うと、注文が作成され、確認メールが送信されること。
  • ログインしていないユーザーが注文を行おうとすると、ログインページにリダイレクトされること。

これにて、今回の単体テストの実装はひと段落にしようと思います。

補足:メール送信 ローカルにて

ここからは少し解説と、メール送信について改良を。
send_mail は、Django のメール送信機能を利用してメールを送信するための関数です。ローカル環境でのメール送信の動作は、設定次第で異なります。以下に send_mail の概要と、ローカル環境でのメール送信設定について説明します。

send_mail の概要

send_mail 関数は、以下のように使用します:

from django.core.mail import send_mail

send_mail(
    subject,         # メールの件名
    message,         # メールの本文
    from_email,      # 送信者のメールアドレス
    recipient_list,  # 受信者のメールアドレスのリスト
    fail_silently=False,  # 失敗時に例外を発生させるか
)

send_mail(
    'Order Confirmation',
    'Thank you for your order!',
    'from@example.com',
    ['to@example.com'],
    fail_silently=False,
)

ローカル環境での動作

ローカル環境でメール送信をテストするためには、settings.py の設定を変更します。Django はいくつかのメールバックエンドをサポートしていますが、ローカル開発環境では以下のいずれかを使用するのが一般的です。

1. コンソールバックエンド

コンソールバックエンドは、メールの内容をコンソール(標準出力)に表示します。これは、メール送信の内容を確認するのに便利です。

# myproject/settings.py

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

2. ファイルバックエンド

ファイルバックエンドは、送信されるメールをファイルに保存します。メールの内容をファイルとして確認したい場合に便利です。

# myproject/settings.py

EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
EMAIL_FILE_PATH = '/tmp/app-messages'  # ここにメールファイルが保存されます

3. SMTPバックエンド

ローカルSMTPサーバーを使用してメールを送信します。例えば、MailHog などのツールを使用してローカルでメールをキャプチャすることができます。

# myproject/settings.py

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025

MailHogの使用例

本当に参考程度に。

  1. MailHogを起動する場合:
    dockerのコンテナつかってやるといいです。
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
  1. settings.py を以下のように設定します:
# myproject/settings.py

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025
  1. ブラウザで http://localhost:8025 にアクセスすると、送信されたメールを確認できます。

ローカル環境でメール送信をテストするための設定方法として、コンソールバックエンド、ファイルバックエンド、SMTPバックエンドの使用方法を紹介しました。これにより、メールの送信機能をテストしやすくなります。開発環境に応じて適切なバックエンドを選択し、設定してください。

補足: メールをテンプレートを使う

HTMLメールや、ファイルを添付したいって場合にあるので一応触れます。
単純なテキストだけのメールだと実用出来じゃないかなと思いましたので追加しました。

メールの内容をHTMLテンプレートを使用して書く方法です。

HTMLテンプレートの作成

まず、メールの内容を定義するHTMLテンプレートを作成します。templates/emails/order_confirmation.htmlのようなファイルを作成します。

order_confirmation.html

templates/emails/order_confirmation.html
<!DOCTYPE html>
<html>
<head>
    <title>Order Confirmation</title>
</head>
<body>
    <h1>Thank you for your order, {{ user.username }}!</h1>
    <p>We have received your order and will process it soon.</p>
    <p>Order Details:</p>
    <ul>
        {% for item in order_items %}
        <li>{{ item.product.name }} - Quantity: {{ item.quantity }}</li>
        {% endfor %}
    </ul>
    <p>Total: {{ total_price }}</p>
</body>
</html>

ビューの変更

ビューでメールを送信する際に、HTMLテンプレートをレンダリングしてその内容をメールに含めます。

store/views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.views import View
from .models import Product, Cart, CartItem, PlaceOrder, OrderItem
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator

class PlaceOrderView(View):
    @method_decorator(login_required)
    def post(self, request):
        cart_id = request.session.get('cart_id')
        if not cart_id:
            return redirect('cart_detail')

        cart = Cart.objects.get(id=cart_id)
        order = PlaceOrder.objects.create(user=request.user)
        order_items = []

        for item in cart.cartitem_set.all():
            order_item = OrderItem.objects.create(
                order=order,
                product=item.product,
                quantity=item.quantity
            )
            order_items.append(order_item)
        
        # Calculate total price
        total_price = sum(item.product.price * item.quantity for item in order_items)

        # Render the HTML template for the email
        html_content = render_to_string('emails/order_confirmation.html', {
            'user': request.user,
            'order_items': order_items,
            'total_price': total_price,
        })

        # Create the email
        email = EmailMultiAlternatives(
            'Order Confirmation',
            f'Thank you for your order {request.user.username}!',
            'from@example.com',
            [request.user.email],
        )
        email.attach_alternative(html_content, "text/html")

        # Send the email
        email.send()

        cart.cartitem_set.all().delete()
        return redirect('order_confirmation', pk=order.pk)

render_to_stringを使用して、HTMLテンプレートをレンダリングし、メールの本文を生成します。
EmailMultiAlternativesを使用してメールを作成し、HTMLコンテンツを添付して送信します。

単体テスト

単体テストも修正します。

store/tests/test_views.py
    @patch('store.views.EmailMultiAlternatives')
    def test_place_order(self, MockEmail):
        mock_email_instance = MockEmail.return_value
        
        self.client.force_login(self.user)
        response = self.client.post(reverse('place_order'))
        self.assertRedirects(response, reverse('order_confirmation', args=[PlaceOrder.objects.first().pk]))

        # Check order and order items creation
        self.assertEqual(PlaceOrder.objects.count(), 1)
        order = PlaceOrder.objects.first()
        self.assertEqual(order.user, self.user)
        self.assertEqual(OrderItem.objects.count(), 1)
        order_item = OrderItem.objects.first()
        self.assertEqual(order_item.product, self.product)
        self.assertEqual(order_item.quantity, 2)

        # Check if the EmailMultiAlternatives method was called
        MockEmail.assert_called_once_with(
            'Order Confirmation',
            'Thank you for your order testuser!',
            'from@example.com',
            [self.user.email],
        )
        # Check if HTML content was attached
        mock_email_instance.attach_alternative.assert_called_once()
        # Check if the email was sent
        mock_email_instance.send.assert_called_once()

設定の確認

メールバックエンドが正しく設定されていることを確認します。開発中はコンソールバックエンドやファイルバックエンドを使用するのが一般的です。

myproject/settings.py
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# または
# EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
# EMAIL_FILE_PATH = '/tmp/app-messages'

まとめ

これで、HTMLテンプレートを使用してメールの内容を定義し、Djangoのビューでそのテンプレートをレンダリングしてメールを送信する方法が設定できました。これにより、よりリッチな内容のメールをユーザーに送信することができます。再度、注文機能をテストして、HTML形式のメールが正しく送信されていることを確認してください。

send_mail()EmailMultiAlternatives の違い

send_mail()

  • 簡単にテキストメールを送信するための関数。
  • 件名、本文、送信者、受信者のリストを指定するだけでメールを送信できる。

EmailMultiAlternatives

  • 複雑なメール(HTMLメール、添付ファイルなど)を送信するためのクラス。
  • テキスト版とHTML版の両方の内容を指定できる。
  • 添付ファイルなども追加できる。

EmailMultiAlternatives を使用する理由

HTMLメールを送信する場合、メールクライアントがHTMLをサポートしていない場合に備えてテキスト版も送信する必要があります。EmailMultiAlternatives を使うと、テキスト版とHTML版の両方を同時に送信できます。

このコードでは、EmailMultiAlternatives クラスを使用して、HTMLテンプレートをレンダリングしてメールの本文に使用しています。これにより、リッチなHTMLメールを送信できるようになります。また、テキストメールの内容も同時に送信することができ、受信者のメールクライアントがHTMLをサポートしていない場合でもメールの内容が適切に表示されます。

send_mail() 関数ではなく EmailMultiAlternatives クラスを使用することで、HTMLメールや複雑なメールの送信が可能になります。

補足: MockとMagicMock

MockMagicMock は、どちらも unittest.mock モジュールに含まれるクラスで、Pythonのテストフレームワークにおいてモックオブジェクトを作成するために使用されます。これらのクラスは、テスト中に外部依存をシミュレートするために使われますが、いくつかの違いがあります。

Mock

  • 基本的なモックオブジェクト:
    • Mock クラスは、メソッドや属性の呼び出しを記録し、それらを検証するための基本的なモックオブジェクトを提供します。
    • Mock オブジェクトは、どんなメソッドや属性も持つことができ、それらに対する呼び出しを追跡します。
    • Mock は、特定の振る舞いを設定するための機能を持っていますが、特別なマジックメソッド(特殊メソッド)に対するサポートは MagicMock ほど豊富ではありません。

使用例

from unittest.mock import Mock

# モックオブジェクトの作成
mock = Mock()

# メソッドの呼び出し
mock.some_method()

# 呼び出しが行われたかの検証
mock.some_method.assert_called_once()

MagicMock

  • 拡張されたモックオブジェクト:
    • MagicMock クラスは、Mock クラスのすべての機能を継承しつつ、特にマジックメソッドに対するサポートを追加したモックオブジェクトがある
    • マジックメソッド(例えば、__getitem____setitem____iter____call__ など)に対するモックを設定したり、呼び出しを追跡したりする機能が追加されている
    • MagicMockMock のスーパーセットであり、Mock でできることはすべて MagicMock でもできる

使用例

from unittest.mock import MagicMock

# マジックメソッドをサポートするモックオブジェクトの作成
magic_mock = MagicMock()

# メソッドの呼び出し
magic_mock.some_method()

# 呼び出しが行われたかの検証
magic_mock.some_method.assert_called_once()

# マジックメソッドの使用
magic_mock.__getitem__.return_value = 'value'
print(magic_mock[0])  # 'value'
magic_mock.__getitem__.assert_called_once_with(0)

使い分け

  • 基本的なモックオブジェクトが必要な場合: Mock クラスを使用します。特別なマジックメソッドを扱う必要がない場合、これで十分
  • マジックメソッドをサポートするモックオブジェクトが必要な場合: MagicMock クラスを使用します。例えば、クラスのインスタンスをモックしたい場合や、オブジェクトが特定のマジックメソッドを使用する場合に便利

つまり、とりあえずMagicMock使えばよほど特殊なことをしない限りOKです。ただし、MagicMock出ないといけない場面自体が特殊なので、正直いうとMockでいいです。

最後に

思ったよりかなり長くなりましたが、実戦で使うにはもう少し書きたいことあったとも思います。ただ、これを参考に単体テストを書き始める人が増えればいいなと願っています!

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4