古生代紀のブログ

主にアプリ開発/その他ナレッジ

REST API設計のTIPS


URI設計

単語は複数形にする


api/user/1
✖︎ api/users/1

 

単語はハイフンでつなげる※ハイフンでつなげるくらいなら区切る

api/users-matching
api/users/matching

 

レスポンスデータフォーマット

エラーにHTMLを含めない
サービス閉塞時は503を返して、クライアント側がいつ再開できるか分かるようにしてあげる

 

セマンティックバージョニング

1.2.3のような表記のバージョン記法のこと

メジャー: 1にあたるところ: 後方互換しない大きな機能変更
マイナー: 2にあたるところ: 後方互換する機能追加変更
パッチ: 3にあたるところ: 後方互換するバグ修正

 

 

 

 

 

旅記録〜青春18松山〜

 

昔はネカフェ民だったのですが、ビジホの安さと快適さを知ってしまったので、最近は宿に泊まってます

今回は主に宿情報の記録をします

総評としては東横インが最強でした

 

旅期間 2021 12/29-1/3

人数 男2人

そして今回の旅は予定をかっちり決めない旅行としているので、宿は当日にとっています

全体としては年末年始ということと、コロナの療養者確保で空きがないところもちらほらあったものの、ビジネスホテルは概ね当日でも空きがあった

なおお高い旅館みたいな宿に関しては調べても空きはなかったです

また、やはりネカフェも休日料金+100-200円を設定しており、ビジネスホテルと比較してもネカフェに泊まっても多くて1000円程度浮くかどうかレベルとなっていたので(ビジホに2人1室とっていたこともあるが)、ネカフェに泊まる選択肢は皆無でした

(駅前のネカフェは大体6時間2000円レベルとなっていた)

東横インは年末年始でもほとんど料金変化が無い様子でした(アパホテルは料金変動激しかったです)

18キッパーの強い味方だと思います

 

 

1日目

関東→名古屋

8時くらいに出発したものの本当はもっと先まで進むつもりだったが、掛川城間に入ったので、急遽掛川城観覧してたら時間なくなったため名古屋で宿泊

掛川城は100名城の一つでもあって天守と御殿の両方ともに見応えあります

JAF会員だと割引ききます

やはりJAFは偉大

f:id:shinseidaiki:20220102193342j:image

 

宿を色々探したが、東横インが最安だったため東横インに決定

ダブルルームしかなかったのでダブルルームで確保

二人合わせて5200円

17時くらいに予約したが、ツインはなし、ダブルルームはあとちょっとの状態でした

なお、東横インはシングルに関しては年末年始でもほぼいつでもどこでもあるような状態だったので枯渇する心配はなさそうです

ここは駅からちょっと遠いが送迎バスが出ているので、移動に困ることはない

名古屋城にも近いので、翌日に名古屋城行くならここに泊まるのがよいとおもいます

部屋の綺麗度も高いです

 

 

2日目

名古屋→丸亀

ここの朝食はレパートリーは少なめだった

朝の送迎バスがあるので、それで9時ごろ名古屋駅へ移動

そこからは特に寄り道せず四国へ

時間の都合上深夜に宿に入りたくないので15:00ごろ宿探し

岡山含めて宿探したが、丸亀のアパホテルに大浴場があるようだったので、丸亀までの移動を思案

坂出と高松と迷うが、お手頃価格で探してたので、そうすると高松は宿見るとシングルすらなかったりして、激混んでそうだったので自動的に丸亀に決定

12/30はどこの宿も高めに値段設定してたので、やはりこのタイミングが年末年始で最も混むタイミングだと思いました

丸亀城もあったので、丸亀に決定

ライトアップされてた丸亀城は綺麗

普段よりデカく見える

現存12天守の一つでもある丸亀城はやはり偉大

f:id:shinseidaiki:20220102192800j:image

 

東横インアパホテルで迷ったが、大浴場のあるアパホテルを選択

年末のためか東横インでも二人合わせて7000円近くしていたので、大浴場があるアパホテルに決定

やはり年末だからか混んでいた

二人で8000円

清潔度は高くない

まあよしとする範囲

喫煙可の部屋だったからかもしれない

禁煙室は埋まっていた

大浴場は綺麗だった

割といいお湯だったので正解

エレベーターがひとつしかないので、混んでると大変な宿って感じ

四国に繁華街のイメージはなかったが、夜中散歩しているとクラブのキャッチが結構いた

丸亀って夜の繁華街あったんですね

意外

年末なので店がほぼ閉まっており、はなまる製麺で晩飯

19時ごろ着だと飯屋はここしか選択肢ないです

 

 

3日目

丸亀→松山

丸亀は年末休業みたいなことがネットにはあったが、ちゃんと9時から入れたので入城

200円でとってもやすい

こじんまりした天守なのですぐ見終わりますが、石垣はめちゃくちゃ立派で高台にあるので、全体的な観覧時間は結構かかります

扇の勾配は見応えあります

伊賀上野の石垣は遠くから眺めるので大きさが分かりにくいですが、丸亀の石垣は真下から見上げられるので、高さに感動できるとおもいます

あと井戸が日本一深いです

60メーターなので落ちたら死にますね

掛川と松山も行ったので、今回謎の井戸深い城コンプしました

同じお城を二巡三巡し始めてるのでもしかしたら今後謎コンセプトでお城巡りするかもしれないです

 

昼過ぎごろに松山目指して17時着

ここからの電車は電車にトイレの設備が皆無なので、トイレ近い人は気をつけた方がいいですね

道後温泉の宿探してみましたがやはり年末年始で人が多いのとそもそもの値段設定が高いので無理して道後で泊まる必要は無さそうです

そうすると松山市内で探すことになって、結局東横イン一人勝ちでした

ツインを二人で5000円です

綺麗度は高め

ちなみに大晦日松山市内の店はほぼ営業しておらず、良さげな店はすべて予約でいっぱいで壊滅状態でした

年末年始にご飯食べに行く場合は下調べが必要ですね

ロッテリアですら20時ごろに閉め始めていたので危なかったです

外食したければ19時までに動かないといけない様子でした

 

 

4日目

松山・道後温泉

この日はずっと松山

松山城坂の上の雲ミュージアム道後温泉いきました

 

この日は松山城に出発

先着200人は記念品あると書いてあったので急いで行きました

例年はタオルらしいのですが、今年はクリアファイルでした

松山城自体はものすごくいいですね

というか大洲城しかり、今治城しかり、宇和島しかり愛媛県はいい城を持ちすぎですね

四国の続も含めた100名城を愛媛が総取りしちゃってるのがなかなか罪な県だと思います

まあでもやっぱり松山城はすごいですね

永遠に残しましょう

f:id:shinseidaiki:20220102220149j:image

 

坂の上ミュージアムは原作観てないのであまりわからないですが、展示物は原作知らなくても楽しめました

あまり本筋と関係ないですが、東郷平八郎も若いころはいい男だったんですね

東郷平八郎の若いころの写真が謎に印象に残りました

 

そして、てくてく歩いて道後へ

ちなみに歩くと遠くてしんどいので素直に路面電車使った方がいいです

そして本命の道後温泉本館は激混みで案内終了だったので、別館へ

別館も1時間30待ちでした

ただ、610円なのに、めっちゃいい設備でした

待つかいはあります

あと入場制限かなりかけてるので、そうじゃないと結構すぐ入れそうでした

f:id:shinseidaiki:20220102215601j:image

道後温泉別館は飛鳥時代をイメージしてるそうな

なかのシャンプーとか石鹸は超いいやつ使ってそうな感じでした

 

この日も同じく同じところの東横インでした

値段は同じです

部屋は少しだけ前の部屋より洗面台が汚れてました

まあ気にならない人にはどうということは無いレベルです

 

5日目

松山→相生

移動日

早朝に道後温泉にまた入りました

この日も10時くらいに本館いくとその時ですでに次回案内が3時と言われました

めっちゃ混んでますね

別館とかは30分待ちとかでした

なんでこんなに差があるんですかね

まあ、やはり夜中が1番混むようです

早い時間帯にいきましょう

 

その後は帰宅です

移動して帰路途中の相生へ

神戸など当たりましたが、微妙に高いのとシングルでも部屋が埋まってました

なので、相生の東横インに決定

ここは駅からめっちゃ近いです

目と鼻の先にあります

アメニティが少なかったです

綺麗度はかなりたかいですね

 

 

 

6日目

帰宅

とくに書くことなし

 

 

総じて

アパホテルはところによっての部屋の綺麗度にばらつきありますが、東横インは安定してますね

安いし朝食つくので、18キッパーは東横オススメですね(36歳以上は知りません)

これ以上のよい宿情報知ってる人は教えてください

まあ昔は宿代相当けちってましたが、働き始めたら1泊3000円くらい簡単に出せるんですね

それなりの文化水準保ちつつも安上がりな旅行したい人はこの記事の情報を参考にしてください

 

東横インが安かったのは35周年にちなんだ35%割引記念があったからみたいですね
今だけっぽいですね

 

 

 

 

 

 

 

 

 

Django実践的アプリ作成ナレッジ ~環境変数設定・決済機能・カスタムユーザー・ログイン処理・非同期処理・キャッシュ制御・ログ・OAuth認証・バッチ処理~

Udemyの学習で学習した Django の実践的な機能の実装方法のナレッジをまとめます

2種類の講座の学習まとめ

講座1: 【中級者向け】Django3でショッピングアプリ開発 - ECサイト構築で行うPython・WEBアプリケーション開発
https://github.com/TakumaFujimoto/vegeket_project

講座2: 最新版:Python + Django3 Djangoを基礎から応用まで、アプリケーション開発マスターpython付き


できること(抜粋)

講座1

講座2

  • OAuth認証
  • バッチ処理
  • ログ出力
  • ログイン処理
  • AJAX非同期処理とキャッシュ制御
  • Python基本文法
  • 関数ベースビュー
  • クラスベースビュー
  • Form
  • エラーハンドリング


目次

自環境

  • OS:Windows 10
  • シェル:powershell
  • Python:3.8系
  • エディター:PyCharm
  • 仮想環境:Anaconda Navigator

ECサイトの構築

仮想環境の準備は以下参照
https://shinseidaiki.hatenablog.com/entry/2021/11/07/232602


使うパッケージ requirements.txt

asgiref==3.4.1
certifi==2021.5.30
charset-normalizer==2.0.3
Django==3.2.5
django-environ==0.4.5
idna==3.2
Pillow==8.3.2
pytz==2021.1
requests==2.26.0
sqlparse==0.4.2
stripe==2.60.0
urllib3==1.26.6

requirements.txtをインストールする場合は上記ファイルを任意のフォルダに配置して、
仮想環境になっていることを確認して
以下のコマンドを仮想環境で実行する

pip install -r requirements.txt

なお記事投稿者が記事記入時はDjangoが4にアップグレードされていたため、上記の方法で環境を合わせた

初期準備

django-admin startproject udemyec
django-admin startapp base

フォルダの作成
templates, secrets, static

Tips!

setting.pyのBASE_DIRはpathlibの組み込み変数でプロジェクトのルートパスを差す
BASE_DIR / 'templates' の書き方でもOSに応じて臨機応変にパスを作ってくれる

Secretsフォルダの利用

GitHubにおおよそのことは書いていた
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec03/README.md

環境変数を扱うためにインストールする
django-environ

環境変数を作成する

secretsフォルダ直下に以下を作成

.env.dev
.env.prod

環境に応じた環境変数を用意しておくことで開発を円滑に行うことが出来るようにする
.env.devは開発環境用
.env.prodは本番環境用(productの略)

環境変数に格納する値はクオテーションなしで登録

.env.dev(開発環境)

SECRET_KEY=django-xxxxxxxxxxxxxx
DEBUG=True
ALLOWED_HOSTS=*

.env.prod(本番環境 - デプロイする場合)

SECRET_KEY=django-xxxxxxxxxxxxxx
DEBUG=false
ALLOWED_HOSTS=[特定のホスト]

setting.pyに環境変数を適用

import environ

env = environ.Env()
root = environ.Path(BASE_DIR / 'secrets')

# 本番環境
# env.read_env(root('.env.prod'))

# 開発環境
env.read_env(root('.env.dev'))


SECRET_KEY = env.str('SECRET_KEY')

DEBUG = env.bool('DEBUG')

ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')

rootはプロジェクト直下に.envファイルを置く場合は記述する必要はない
本番環境と開発環境で自動で分ける場合はif文を使って条件分岐をする必要がある模様
具体的な例はわからなかったが、以下が参考になるかもしれない
https://ikura-lab.hatenablog.com/entry/2019/06/16/142339

.gitignoreに関してはこちらの記事などを参照にする
どこかでまとめる必要がある
https://leading-tech.jp/wiseloan/gitignore/

このあたりを記述

*.log
*.pyc
__pycache__/
secrets/
db.sqlite3

モデルとかテンプレートの作成

省略

この人のフォルダ構成はtemplatesフォルダ内に以下のフォルダとファイルがある
base.html
pagesフォルダ
snippetsフォルダ

modelsフォルダを作成してその直下に__init__.pyファイルを配置すればフォルダがmodelsとして認識される
modelsが増えてきた場合に有効
フォルダ化にはmodelごとに自由なファイル名のモデルを定義することができる
なお、initiは初回に読み込まれるので、以下のような記述を行っておくとモデルが読み込まれる
__init__.py

from .item_models import *

画像を別のストレージで使用する場合については別途調査が必要

Itemの外部キーのcategoryのondeleteについてはon_delete=models.SET_NULLにした方がよい
なぜならカテゴリ削除と同時にItemを削除したくはないから
カテゴリが削除された場合はNullを詰める

ManyToManyでリレーションをとる場合はtagsのような複数形の名前にする方が慣例の模様
ManyToManyの場合は中間テーブルが内部的に作成されて直接つながっていないためondeleteは設定する必要がない

ここではカスタムユーザーモデルは先に作ってない
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec06/README.md

admin画面はunregisterを使用すると非表示にすることもできる

クラスベースビューと関数ベースビューの違い

クラスベース

class IndexListView(ListView):
    model = Item
    template_name = 'pages/index.html'


関数ベースで書いた場合

def index(request):
  object_list = Item.objects.all()
  context = { 'object_list': object_list, }
  return render(request, 'index.thml', context )
Tips!

for文の中で使える便利なものが存在する
{{ forloop }}
for文が何回回ったかなどを出力できる

Tips! get post

def postやdef getをクラスベースビューで記述するとPOSTの時やGETメソッドの時に実行される関数を記述することができる

class AddCartView(View):
    def post(self, request):
Tips! OrderdDict

OrderedDict()はPython標準ライブラリで搭載されている
辞書の場合、追加するときにListと違って順番が保持されないために順番を保持するために使用できるメソッド
順序月の辞書が作れる

Tips! プチ環境変数

消費税率などの見られてもよい環境変数にしたい情報はsetting.pyに直接記入できる

TAX_RATE = 0.1
Tips! get_queryset, get_context_data

get_querysetについて

データをDBなどから取得するメソッド
ListViewがget_querysetをもともと持っており、デフォルトではモデルのすべてのアイテムを返すようになっている
カスタマイズする場合はget_querysetをオーバーライドする
また、obj.quantityやobj.subtotal のようにmodelsに定義されていないquantityのような値はviewにおいて新たに定義することのできるインスタンス変数となり、DBには保存されないがプログラムで自由に使用することができるようになる
あらかじめモデルで定義していなくても自由にインスタンス変数を追加することができる
self.queryset = []で定義してフォー分で回して、self.queryset.append(obj)したりしてquerysetを作成するようにして作る

get_context_data

テンプレートに値を渡すメソッド
context["total"] = self.total のような記述をすることでテンプレート上でtotalというキーで値にアクセスすることができるようになる
また以下のようにjsonから辞書型に変換して画面に値を渡すことも可能
context["items"] = json.loads(obj.items)

Tips!

管理画面者の編集
以下のadminの中のファイルローカルにコピーして上書きすることで編集することが可能(元ファイルは当然ながら編集しない)
https://github.com/django/django/tree/main/django/contrib/admin/templates

例えば、base_site.htmlをtemplateルートフォルダ下(setting.pyのDIRSのところで設定している場所、基本はプロジェクト直下にテンプレートを指定することが一般的)にadminフォルダを作成し、その下にgithub上のdjangoテンプレートと同じフォルダ構成でファイルを配置する
base_site.htmlの内容を上書きするとadminサイトに変更が反映される

CSSを書き換えたい場合は 管理画面はstatic: admin/css/base.cssを継承しているためこのファイルを作成して継承すればいい(CSSなのでstaticフォルダ以下であるところに注意)
Github上の場所は以下
https://github.com/django/django/tree/main/django/contrib/admin/static/admin/css


決済機能

決済機能についての簡単な説明がある
https://github.com/TakumaFujimoto/vegeket_project/blob/main/sec10/README.md

ここではStripeを利用する
Stripeで開発者向けの開発用の決済機能を無料で試すことができる
Stripe: 世界で最もシェアを獲得するクレジットカード決済代行サービス
各種セキュリティ・法規制に対応しているため決済代行サービスを使わないで課金が必要な自作サービスを実装する選択肢はほぼ皆無だと思われる(特に個人)
使用時の手数料3%以外の費用がないため個人でマッチングアプリを実装したい場合(年齢確認用)などで利用できる

Stripeのドキュメント
Stripe のドキュメント

Stripe使用準備

アカウントの作成
上記ストライプの公式サイトからアカウントを作成する(メール認証があるので認証を済ませる)
アカウントを作成するとダッシュボードに移動する
最初から開発環境を提供してくれている模様
本番環境を利用する場合は別途申請が必要となる
ここではすべて開発環境だけを使用する

Stripeパッケージのインストール

pip install stripe
Stripeに使用するテンプレート画面の作成

Stripeドキュメントに記載されるテンプレートを使用する
https://stripe.com/docs/payments/checkout
こちらにサンプルプロジェクトが記載されている
https://stripe.com/docs/checkout/quickstart
講座の時とは内容が更新されている模様
フィルタリングがあるので、HTML PYTHONを選択して今回はPythonのテンプレートを表示して模倣する

テンプレートとしては以下がある

  • server.py
  • checkout.html チェックアウト、ECサイトにおいてはショッピングカートページを差す画面の見本
  • success.html 決済が成功した後に表示される画面
  • cancel.html 決済が失敗した場合や戻るボタンを押された場合に表示する画面

テンプレートはあくまで三本であり、部分的に使用できる場合は、必要な箇所の機能だけを模倣すればよい

success.htmlを作成する

templates/pages/success.html

{% extends 'base.html' %}
 
{% block main %}
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Thank you!</h1>
      <!-- 注文履歴確認ページ -->
      <p>注文履歴は<a href="/orders/">こちら</a></p>
    </div>
  </div>
</div>
{% endblock %}
cancel.htmlを作成する

templates/pages/cancel.html

{% extends 'base.html' %}
 
{% block main %}
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Cancel</h1>
      <p>うまく処理されませんでした。カートは<a href="/cart/">こちら</a></p>
    </div>
  </div>
</div>
{% endblock %}
.envを編集する

環境変数にSecrets情報を格納しておく
今回は開発環境のみなので、.env.devのみを編集する

secrets/.env.dev

STRIPE_API_KEY=[STRIPEのAPIキー]
MY_URL=http://127.0.0.1:8000

[STRIPEのAPIキー]は公式のダッシュボードからコピーして貼り付ける
https://dashboard.stripe.com/

ホームの真ん中の方に開発者向けという項目があるので、シークレットキーをコピペする
なお、公開可能キーは自分でカスタマイズした決済ページを実装する場合に必要になってくるものになる
Stripeの決済ページにリダイレクトする今回のような方法の場合ではシークレットキーのみでよい
今使用しているシークレットキーは開発環境用のテスト用シークレットキーとなっており、本番環境で使用する場合は、別途申請を行って本番環境用のシークレットキーを入手して、 .env.prod に本番環境用のシークレットキーをコピペして使用する

setting.pyの編集

設定ファイルにシークレットキーの環境変数を読み込ませる

setting.pyに以下を追記

# Stripe API Key
STRIPE_API_SECRET_KEY = env.str('STRIPE_API_SECRET_KEY')

# スキーマ&ドメイン
MY_URL = env.str('MY_URL')
ビューの作成

base/views/pay_views.py

from django.shortcuts import redirect
from django.views.generic import View, TemplateView
from django.conf import settings
from stripe.api_resources import tax_rate
from base.models import Item
import stripe

# Stripeのシークレットキーを読み込む 
stripe.api_key = settings.STRIPE_API_SECRET_KEY
 
 
class PaySuccessView(TemplateView):
    template_name = 'pages/success.html'
 
    def get(self, request, *args, **kwargs):
        # 最新のOrderオブジェクトを取得し、注文確定に変更
 
        # カート情報削除
        del request.session['cart']
 
        return super().get(request, *args, **kwargs)
 
 
class PayCancelView(TemplateView):
    template_name = 'pages/cancel.html'
 
    def get(self, request, *args, **kwargs):
        # 最新のOrderオブジェクトを取得
 
        # 在庫数と販売数を元の状態に戻す
 
        # is_confirmedがFalseであれば削除(仮オーダー削除)
 
        return super().get(request, *args, **kwargs)
 
 
tax_rate = stripe.TaxRate.create(
    display_name='消費税',
    description='消費税',
    country='JP',
    jurisdiction='JP',
    percentage=settings.TAX_RATE * 100, # 100分率整数で渡す必要がある
    inclusive=False,  # 外税(税別)を指定(内税(税込)の場合はTrue)
)
 
 
def create_line_item(unit_amount, name, quantity):
    return {
        'price_data': {
            'currency': 'JPY',
            'unit_amount': unit_amount,
            'product_data': {'name': name, }
        },
        'quantity': quantity,
        'tax_rates': [tax_rate.id]
    }
 
 
class PayWithStripe(View):
 
    def post(self, request, *args, **kwargs):
        cart = request.session.get('cart', None)
        if cart is None or len(cart) == 0:
            return redirect('/')
 
        line_items = []
        for item_pk, quantity in cart['items'].items():
            item = Item.objects.get(pk=item_pk)
            line_item = create_line_item(
                item.price, item.name, quantity)
            line_items.append(line_item)
 
        checkout_session = stripe.checkout.Session.create(
            # customer_email=request.user.email,
            payment_method_types=['card'],
            line_items=line_items,
            mode='payment',
            success_url=f'{settings.MY_URL}/pay/success/',
            cancel_url=f'{settings.MY_URL}/pay/cancel/',
        )
        return redirect(checkout_session.url)

PaySuccessViewは決済が成功した場合のリダイレクトのためのView
PayCancelViewは決済が失敗した場合のリダイレクトのためのViewであり、
実際の決済を行う処理はPayWithStripeとなる
PayWithStripeはpostのみでアクセス可能とする
公式ドキュメントのテンプレートのserver.pyを参考に作成していく

line_itemsはStripeの決済ページに表示させるデータを準備している
載せられるデータは詳しくはドキュメント参考だが、今回載せている'price_data'は価格であり、ドキュメントの'price'よりもより詳しく記載できている
'quantity': quantity, は購入数量
'tax_rates': [tax_rate.id] は任意で載せることができるが、これを載せていると自動で税金計算をしてくれるなど便利になる

checkout_sessionにline_itemsといったデータを追加送信すると決済ページにその情報を表示させることが可能となる
Stripeとの通信に必要な情報はcheckout_sessionを使用するので、これを必ず定義する
checkout_sessionの要素はドキュメント(https://stripe.com/docs/checkout/quickstart)を参照
なお、success_url=f'{settings.MY_URL}/pay/success/',のように{settings.MY_URL}を付与しておかないとStripeの決済画面のURLからの相対パスにリダイレクトしてしまうため、URLにドメインを追加しておく

最後は return redirect(checkout_session.url) でStripe公式がデフォルトで用意している決済画面にリダイレクトする

views.pyではなくviewsフォルダを作成してviewsを実装している場合はviews/__init__.pyのpay_viewsのインポートを忘れないように

from .pay_views import *
ルーティングの設定

'pay/checkout/'のURLエンドポイントはStripeの決済画面にリダイレクトするページとなっている

urls.py

    # Pay
    path('pay/checkout/', views.PayWithStripe.as_view()),
    path('pay/success/', views.PaySuccessView.as_view()),
    path('pay/cancel/', views.PayCancelView.as_view()),
動作確認

サーバーを起動させる
商品を選択してカートからcheckoutボタンを押す

成功させたい場合は4242 4242 4242 4242
支払いに確認を必要とする場合は4000 0025 0000 3155
支払いが拒否される場合は4000 0000 0000 9995
を入力する
カード番号以外は適当な値を入力してかまわない

それぞれのパターンの挙動が正しければ動作確認はOK
これで基本的な決済機能を実装できるようになった

トラブルシュート

以下のようなエラーが発生した場合は、Stripeのアカウント名を変更する必要がある模様(参考https://note.com/daikinishimatsu/n/n029a7ff01f62

InvalidRequestError at /pay/checkout/
In order to use Checkout, you must set an account or business name at https://dashboard.stripe.com/account.

ダッシュボードにアクセスし(https://dashboard.stripe.com/)、プロフィール>アカウントのところで「名称未設定」のアカウントと表示されていればアカウント名を登録する
登録方法は 設定(歯車アイコン)>アカウントの詳細>アカウント名 で好きなアカウント名を入力する
保存を押すことでアカウント名は保存される

エラーハンドリング

公式ドキュメント
https://docs.djangoproject.com/en/4.0/ref/views/#error-views

事前準備
setting.pyのDEBUGをFalseにする
DEBUGでない場合はホストALLOWED_HOSTの追加必要 '127.0.0.1'
プロジェクトのurls.pyにステータスコードを設定したハンドラーを追加する

handler404 = views.関数名()

なお、デフォルトではNot Foundエラーやサーバーエラー画面の味気ない画面が出力される
間違ったURLを正しいURLにリダイレクトなどカスタムしたい場合にエラーハンドリングをカスタマズする

例:間違ったURLの場合はリダイレクトする例

プロジェクト側 urls.py

handler404 = show_error_page

views.py

def show_error_page(request, exception):
    # return render(request, ' 404.html')
    return redirect('store:home')

カスタムユーザモデルを作成する

Djangoは通常models.pyにカスタムユーザーを使用することを明示せずにマイグレーションをすると、初めのマイグレーションでデフォルトユーザーを作成されてしまう
Djangoの公式ドキュメントに記述されている通りデフォルトユーザーを使用してしまうと後からの変更が困難となるので、基本的には最初からカスタムユーザーを作成しておくことが推奨されている

今回は一度マイグレーションを上記でしてしまっているが、この状態からカスタムユーザーを作成することも一応できる
後からカスタムユーザーを作成する大まかな流れとしては、カスタムユーザーをmodels.pyで作成して、DBを削除して、migrationsフォルダのinit以外のファイルを削除して、cacheも削除して、マイグレーションの初期化を行って、その後もう一度マイグレーションを行う(マイグレーションを一からやり直すことでカスタムユーザーを適用させることができる)

カスタムユーザーの作り方としてはAbstractUserを継承する方法と、AbstractBaseUserを継承する方法の2種類がある

  • AbstractUser: すでに存在するフィールドをそのまま流用して、フィールドの増減を行うことができる

ただし、メールアドレスをログインIDにするなどのことができないため、限定的な利用にとどまる

  • AbstractBaseUser: Userモデルをほとんどゼロベースから作成することが可能

自由度が高くこちらをカスタムユーザーとするのが一般的

以下はAbstractBaseUserのカスタム実装例

モデルの作成

models.pyにドキュメントを参考にしたモデルを作成する
https://docs.djangoproject.com/ja/3.2/topics/auth/customizing/#a-full-example
もしくは下の章で紹介しているカスタムモデルの例を参考にする

AbstractBaseUserを使用する場合は、データ挿入取得更新処理をするマネージャークラスを作成する必要がある
以下のコード例ではclass UserManager(BaseUserManager): の部分である
ほぼお決まりの書き方があり、通常ユーザーcreate_userと管理者ユーザーcreate_superuserの2種類のユーザー新規作成メソッドをオーバーライドする
自力で書こうとするとcreate_superuserを忘れがちなため注意が必要

カスタムモデルのログインIDを変更する場合はUSERNAME_FIELDに使用したいフィールドを記載する
ここでは'username'をログインIDにしているが、'email'を指定することもできる
'email'を指定した場合は、ログインIDにEmailを使用することが可能となる
REQUIRED_FIELDSはただの必須項目の設定である

また、ユーザーの生年月日やニックネームなどの情報はUserモデルに直接フィールドを持たせるよりも分離したProfileモデルに持たせるような実装で作成するのが慣習となっている
Userモデルと1to1のリレーションでテーブル分離したProfileモデルを作成する
models.OneToOneFields(User, ....) を使用すると1対1のリレーションを張ることができる

account_models.py

from django.dispatch import receiver
from django.db.models.signals import post_save
from django.db import models
from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
 
 
class UserManager(BaseUserManager):
 
    def create_user(self, username, email, password=None):
        if not email:
            raise ValueError('Users must have an email address')
        user = self.model(
            username=username,
            email=self.normalize_email(email),
        )
        user.set_password(password)
        user.save(using=self._db)
        return user
 
    def create_superuser(self, username, email, password=None):
        user = self.create_user(
            username,
            email,
            password=password,
        )
        user.is_admin = True
        user.save(using=self._db)
        return user
 
 
class User(AbstractBaseUser):
    id = models.CharField(default=get_random_string(22), primary_key=True, max_length=22)
    username = models.CharField(
        max_length=50, unique=True, blank=True, default='匿名')
    email = models.EmailField(max_length=255, unique=True)
    is_active = models.BooleanField(default=True)
    is_admin = models.BooleanField(default=False)
    objects = UserManager()
    USERNAME_FIELD = 'username'
    EMAIL_FIELD = 'email'
    REQUIRED_FIELDS = ['email', ]
 
    def __str__(self):
        return self.email
 
    def has_perm(self, perm, obj=None):
        "Does the user have a specific permission?"
        # Simplest possible answer: Yes, always
        return True
 
    def has_module_perms(self, app_label):
        "Does the user have permissions to view the app `app_label`?"
        # Simplest possible answer: Yes, always
        return True
 
    @property
    def is_staff(self):
        "Is the user a member of staff?"
        # Simplest possible answer: All admins are staff
        return self.is_admin
 
 
class Profile(models.Model):
    user = models.OneToOneField(
        User, primary_key=True, on_delete=models.CASCADE)
    name = models.CharField(default='', blank=True, max_length=50)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    # 住所や年齢などアプリに必要なユーザー情報を設定する
 
    def __str__(self):
        return self.name
 
 
# OneToOneFieldを同時に作成
@receiver(post_save, sender=User)
def create_onetoone(sender, **kwargs):
    if kwargs['created']:
        Profile.objects.create(user=kwargs['instance'])

@receiverの処理はUserが作成されると同時にProfileモデルのフィールドにもデータを作成する処理となっているが、プロフィールはユーザーがアクティブなってから項目を入力されて新規作成されるものだとも考えられるので、この処理はあってもなくてもよい
ただこの処理があるとUserが作成された際にProfileが作成されるためProfileの新規作成処理を別途作成する必要がなくなる

カスタムモデルを使用するという宣言をするためのsetting.pyの編集

カスタムユーザーモデルを使用する宣言を行うために以下のコードを追加する
setting.py

AUTH_USER_MODEL = 'base.User'

※baseの部分はアプリ名
この設定を忘れるとDjangoのデフォルトユーザーモデルが作成されてしまう


またログインのURL情報や、ログイン成功時のリダイレクトURLもsetting.pyに記載することができる
ログアウトも同様
setting.py

LOGIN_URL = '/login/'
 
LOGIN_REDIRECT_URL = '/'
 
LOGOUT_URL = '/logout/'
 
LOGOUT_REDIRECT_URL = '/login/'
ユーザー作成用のフォームを作成する

base/forms.pyを作成する

from django import forms
from django.contrib.auth import get_user_model
 
 
class UserCreationForm(forms.ModelForm):
    password = forms.CharField()
 
    class Meta:
        model = get_user_model()
        fields = ('username', 'email', 'password', )
 
    def clean_password(self):
        password = self.cleaned_data.get("password")
        return password
 
    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password"])
        if commit:
            user.save()
        return user

clean_passwordはバリデーション
不正なパスワードはここで弾かれる

管理者画面にカスタムユーザーを表示させられるように admin.py を編集する

admin.py

from base.forms import UserCreationForm
from django.contrib.auth.admin import UserAdmin

class ProfileInline(admin.StackedInline):
    model = Profile
    can_delete = False


class CustomUserAdmin(UserAdmin):
    fieldsets = (
        (None, {'fields': ('username', 'email', 'password',)}),
        (None, {'fields': ('is_active', 'is_admin',)}),
    )

    list_display = ('username', 'email', 'is_active',)
    list_filter = ()
    ordering = ()
    filter_horizontal = ()

    add_fieldsets = (
        (None, {'fields': ('username', 'email', 'is_active',)}),
    )

    add_form = UserCreationForm

    inlines = (ProfileInline,)

admin.site.register(User, CustomUserAdmin)

ちなみに行を分けると管理画面上の表示が改行されて見やすくなる
fieldsの中身は列挙すると順に横に表示されていく
fieldsets = (
(None.......), # 1行目表示
(None.........), # 2行目表示
)
またこの例ではInlineを利用して、ProfileをUserの管理画面に表示させている
管理者画面でのユーザー追加にはforms.pyで作成したUserCreationFormを指定することで管理者画面でユーザーを作成することができる
またこの例ではadd_fieldsetsにpasswordのフィールド項目が抜けているように思われる

マイグレーション初期化(一度マイグレーションをしてしまった後に後からカスタムユーザーを適用する場合)

アプリ(base)下のmigrationsフォルダ下の__init__.py以外の0001_initial.pyなどのファイルをすべて削除する
同様に、migrationsフォルダ下の__pycache__フォルダ下も同様にinitだけ残して削除する
また、データベースも削除するので、DjangoのデフォルトのSQLiteを使用している場合はdb.sqlite3を削除するだけでよい
別のデータベースを使用している場合はそのデータベースの中身を削除するか、setting.pyのデータベースの接続設定などを変えるなどしてDBを新規作成する

マイグレーションをしてカスタムユーザーを適用する

初めてのマイグレーションかもしくはマイグレーション初期化が終わったら、マイグレーションを行って、先ほど作成したカスタムユーザーを適用する

py .\manage.py makemigrations
py .\manage.py migrate

管理者を作成できるか確認し、管理者画面にログインできるか確認する

py .\manage.py createsuperuser
ログイン・サインアップ画面を作成する

ログイン・新規登録兼用画面
login_signup.html

{% extends 'base.html' %}
 
{% block main %}
<div class="container my-5">
  <div class="row ">
    <div class="col-12">
      <h1>
        {% if 'login' in request.path %}
        Login
        {% elif 'signup' in request.path %}
        Signup
        {% endif %}
      </h1>
      <form method="POST">
        {% csrf_token %}
        <div class="form-row">
          <div class="form-group col-md-4">
            <input type="text" class="form-control" name="username" placeholder="Username">
          </div>
        </div>
        <div class="form-row">
          <div class="form-group col-md-4">
            <input type="email" class="form-control" name="email" placeholder="Email" required>
          </div>
        </div>
        <div class="form-row">
          <div class="form-group col-md-4">
            <input type="password" class="form-control" name="password" placeholder="Password" required>
          </div>
        </div>
        <button class="btn btn-info btm-sm" type="submit">
          {% if 'login' in request.path %}
          Login
          {% elif 'signup' in request.path %}
          Signup
          {% endif %}
        </button>
      </form>
    </div>
  </div>
</div>
 
{% endblock %}
ユーザー情報変更用画面を作成する

ユーザーが自身のアカウント情報を変更することができる画面を作成する
account.html

{% extends 'base.html' %}
 
{% block main %}
 
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Account</h1>
      <form method="POST">
        {% csrf_token %}
        <div class="form-row">
          <div class="form-group col-md-4">
            <label>Username</label>
            <input class="form-control" type="text" name="username" placeholder="name" value="{{user.username}}">
          </div>
        </div>
        <div class="form-row">
          <div class="form-group col-md-4">
            <label>Email</label>
            <input class="form-control" type="email" name="email" placeholder="Email" value="{{user.email}}" required>
          </div>
        </div>
        <button type="submit" class="btn btn-primary">Save</button>
      </form>
    </div>
  </div>
</div>
 
{% endblock %}
プロフィール変更用画面を作成する

プロフィールを変更することのできる画面を作成する
profile.html

{% extends 'base.html' %}
 
{% block main %}
 
<div class="container my-5">
  <div class="row">
    <div class="col-12">
      <h1>Profile</h1>
      <form method="POST">
        {% csrf_token %}
        <div class="form-group ">
          <label>Name</label>
          <input class="form-control" type="text" name="name" placeholder="name" value="{{user.profile.name}}">
        </div>
        <div class="form-row">
          <div class="form-group col-md-2">
            <label>フォーム入力値1</label>
            <input class="form-control" type="text" name="zipcode" placeholder="zipcode"
              value="{{user.profile.zipcode}}">
          </div>
        <button type="submit" class="btn btn-primary">Save</button>
      </form>
    </div>
  </div>
</div>
 
{% endblock %}

form-rowで入力項目を増やす場合はform-groupで囲われている箇所を繰り返し記述する

Viewを作成してログイン・サインアップ・アカウント情報更新機能を作成する

アカウント情報を画面に表示するためのViewを作成する
account_views.py

from django.views.generic import CreateView, UpdateView
from django.contrib.auth.views import LoginView
from django.contrib.auth import get_user_model
from base.models import Profile
from base.forms import UserCreationForm
 
 
class SignUpView(CreateView):
    form_class = UserCreationForm
    success_url = '/login/'
    template_name = 'pages/login_signup.html'
 
    def form_valid(self, form):
        return super().form_valid(form)
 
 
class Login(LoginView):
    template_name = 'pages/login_signup.html'
 
    def form_valid(self, form):
        return super().form_valid(form)
 
    def form_invalid(self, form):
        return super().form_invalid(form)
 
 
class AccountUpdateView(UpdateView):
    model = get_user_model()
    template_name = 'pages/account.html'
    fields = ('username', 'email',)
    success_url = '/account/'
 
    def get_object(self):
        # URL変数ではなく、現在のユーザーから直接pkを取得
        self.kwargs['pk'] = self.request.user.pk
        return super().get_object()
 
 
class ProfileUpdateView(UpdateView):
    model = Profile
    template_name = 'pages/profile.html'
    fields = ('name', '..', ......)
    success_url = '/profile/'
 
    def get_object(self):
        # URL変数ではなく、現在のユーザーから直接pkを取得
        self.kwargs['pk'] = self.request.user.pk
        return super().get_object()

SignUpViewは新規アカウント作成のためのView
LoginViewはログインのためのView
Viewはメソッドを二つに分ける模様
AccountUpdateViewはアカウント情報の更新用View
またユーザーアカウント情報の更新の際の注意点は、URLからIDを取得するのではなく、現在のユーザーから直接PKを取得する(self.request.user.pk)
またフィールドはリストでもよいが、タプルを使用するのが一般的らしい
LoginRequiredMixinを使用すると、ログイン制約をViewにつけることができる(ログインしていなければアクセスさせないということが実現できる)
LoginRequiredMixinはViewクラスの第一引数に記載すると自動的に適用される

ルーティングurls.pyを作成する

urls.py

from django.contrib.auth.views import LogoutView

    # Account
    path('login/', views.Login.as_view()),
    path('logout/', LogoutView.as_view()),
    path('signup/', views.SignUpView.as_view()),
    path('account/', views.AccountUpdateView.as_view()),
    path('profile/', views.ProfileUpdateView.as_view()),
ログアウト機能の実装

ログアウト機能は組み込みのViewが存在するため、それを利用する
上記で設定しているこの部分で実装が完了する
urls.py

from django.contrib.auth.views import LogoutView

path('logout/', LogoutView.as_view()),
Tips! Djangoテンプレートエンジン使用時のHTMLでのログイン判定

{% if user.is_authenticated %} でログイン判定することが可能

カスタムユーザーを使用したログイン処理(関数ベースView)

Tips! Modelマネージャー

Modelクラスはテーブル定義を記述するだけでなくデータ挿入取得更新処理をするマネージャーに分ける場合がある
以下のユーザークラスはマネージャクラスを用いている
※ UserManager(BaseUserManager)の処理

ユーザー登録

以下のようなコードを書けばよい

models.py カスタムユーザーを使用する場合

from django.db import models
from django.contrib.auth.models import (
    BaseUserManager, AbstractBaseUser, PermissionsMixin
)

class UserManager(BaseUserManager):

    def create_user(self, username, email, password=None):
        if not email:
            raise ValueError('Enter Email!')
        user = self.model(
            username=username,
            email=email
        )
        user.set_password(password)
        user.save(using=self._db)
        return user
    
    def create_superuser(self, username, email, password=None):
        user = self.model(
            username=username,
            email=email,
        )
        user.set_password(password)
        user.is_staff = True
        user.is_active = True
        user.is_superuser = True
        user.save(using=self._db)
        return user

class Users(AbstractBaseUser, PermissionsMixin):
    username = models.CharField(max_length=255)
    age = models.PositiveIntegerField()
    email = models.EmailField(max_length=255, unique=True)
    is_active = models.BooleanField(default=False)
    is_staff = models.BooleanField(default=False)
    picture = models.FileField(null=True, upload_to='picture/')

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    class Meta:
        db_table = 'users'

views.py

def regist(request):
    regist_form = forms.RegistForm(request.POST or None)
    if regist_form.is_valid():
        try:
            regist_form.save()
            return redirect('accounts:home')
        except ValidationError as e:
            regist_form.add_error('password', e)
    return render(
        request, 'accounts/regist.html', context={
            'regist_form': regist_form,
        }
    )

form.py

from django.contrib.auth.password_validation import validate_password


class RegistForm(forms.ModelForm):
    username = forms.CharField(label='名前')
    age = forms.IntegerField(label='年齢', min_value=0)
    email = forms.EmailField(label='メールアドレス')
    password = forms.CharField(label='パスワード', widget=forms.PasswordInput())
    confirm_password = forms.CharField(label='パスワード再入力', widget=forms.PasswordInput())
    
    class Meta():
        model = Users
        fields = ('username', 'age', 'email', 'password')
    
    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data['password']
        confirm_password = cleaned_data['confirm_password']
        if password != confirm_password:
            raise forms.ValidationError('パスワードが異なります')

    def save(self, commit=False):
        user = super().save(commit=False)
        validate_password(self.cleaned_data['password'], user)
        user.set_password(self.cleaned_data['password'])
        user.save()
        return user
ユーザーを仮登録してその後本登録する処理の実装

方針
Djangoのユーザーの場合はユーザークラスにもともと登録されているis_activateを使用して制御する

Djangoのシグナル機能を利用してユーザー作成時作成後などの特定のイベントに起因するイベント処理を簡単に実行することができる
この機能をDjangoではシグナルと言う
@receiverアノテーションを関数の先頭に付与して使うことができる
@receiver(post_save, sender=User)
def_save_profile(sender, instance, **kwargs):
instance.profile.save()
のような使い方となる
この場合はユーザーモデルが保存されたタイミングで処理が実行される関数となる
他にもpre_save, pre_delete, post_deleteなどがある

このシグナルを利用して、Djangoでユーザーが作成されたタイミングでトークンを発行するような処理を作る
コンソールにトークンを表示する処理を記述する(本番サービスではメール認証にするべき)
models.py

from django.db.models.signals import post_save
from django.dispatch import receiver

class UserActivateTokens(models.Model):

    token = models.UUIDField(db_index=True)
    expired_at = models.DateTimeField()
    user = models.ForeignKey(
        'Users', on_delete=models.CASCADE
    )

    objects = UserActivateTokensManager()

    class Meta:
        db_table = 'user_activate_tokens'

@receiver(post_save, sender=Users)
def publish_token(sender, instance, **kwargs):
    user_activate_token = UserActivateTokens.objects.create(
        user=instance, token=str(uuid4()), expired_at=datetime.now() + timedelta(days=1)
    )
    # 本番サービスではメールでURLを送る実装とする
    print(f'http://127.0.0.1:8000/accounts/activate_user/{user_activate_token.token}')

accounts/activate_user/{user_activate_token.token}
のエンドポイントでアカウントを有効化する処理をviews.pyの中に追記する

models.pyに有効化処理をまず記述してそのあとviews.pyにこの処理を呼び出す処理を追加
models.py

class UserActivateTokensManager(models.Manager):

    def activate_user_by_token(self, token):
        user_activate_token = self.filter(
            token=token,
            expired_at__gte=datetime.now()
        ).first()
        user = user_activate_token.user
        user.is_active = True
        user.save()


エンドポイントから呼び出される処理 (urls.pyのルーティング処理などは基本なので省略)
views.py

def activate_user(request, token):
    user_activate_token = UserActivateTokens.objects.activate_user_by_token(token)
    return render(
        request, 'accounts/activate_user.html'
    )
ログインの処理

models.py
特になし

views.py

def user_login(request):
    login_form = forms.LoginForm(request.POST or None)
    if login_form.is_valid():
        email = login_form.cleaned_data.get('email')
        password = login_form.cleaned_data.get('password')
        user = authenticate(email=email, password=password)
        if user:
            if user.is_active:
                login(request, user)
                messages.success(request, 'ログイン完了しました。')
                return redirect('accounts:home')
            else:
                messages.warning(request, 'ユーザがアクティブでありません')
        else:
            messages.warning(request, 'ユーザかパスワードが間違っています')
    return render(
        request, 'accounts/user_login.html', context={
            'login_form': login_form,
        }
    )

forms.py

class LoginForm(forms.Form):
    email = forms.CharField(label="メールアドレス")
    password = forms.CharField(label="パスワード", widget=forms.PasswordInput())
ログアウト処理 ( & ログアウト完了時のメッセージについてmessageフレームワークを使用する )

django.contrib.messagesのパッケージに各種エラーやデバッグ・ログイン時ログアウト時などのタイミングでメッセージを簡単に表示させることのできるmessagesというパッケージがある

以下のような書き方となる

messages.debug(request, 'メッセージ')
messages.info(request, 'メッセージ')
messages.success(request, 'ログイン完了しました')
messages.warning(request, '注意')
messages.error(request, 'エラー発生')

例:ログアウトの処理などで以下のように書くとよい
views.py

from django.contrib.auth.decorators import login_required

@login_required
def user_logout(request):
    logout(request)
    messages.success(request, 'ログアウトしました')
    return redirect('accounts:home')


テンプレートでは使用するテンプレートごとに (user_login.htmlなど) にmessagesを書いておく
user_login.html

{% if messages %}
  {% for message in messages %}
    <div>{{ message.message }}</div>
  {% endfor %}
{% endif %}
パスワード変更処理

パスワードは変更後にsession情報を更新する必要がある
またパスワード確認フィールドもあることもポイント

フォームの情報でDBの (上書き) 更新は以下のような形になる
第二引数のinstanceに上書きしたい情報が格納される
forms.PasswordChangeForm(request.POST or None, instance=request.user)
この情報のままで保存するのは危険であるので、viewsのtryの処理のようなvalidationチェックを行う
update_session_auth_hash(request, request.user)でセッション情報を更新する

models.py
特になし

views.py

@login_required
def change_password(request):
    password_change_form = forms.PasswordChangeForm(request.POST or None, instance=request.user)
    if password_change_form.is_valid():
        try:
            password_change_form.save()
            messages.success(request, 'パスワード更新完了しました。')
            update_session_auth_hash(request, request.user)
        except ValidationError as e:
            password_change_form.add_error('password', e)
    return render(
        request, 'accounts/change_password.html', context={
            'password_change_form': password_change_form,
        }
    )


forms.py

class PasswordChangeForm(forms.ModelForm):

    password = forms.CharField(label='パスワード', widget=forms.PasswordInput())
    confirm_password = forms.CharField(
        label='パスワード再入力', widget=forms.PasswordInput())

    class Meta():
        model = Users
        fields = ('password', )

    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data['password']
        confirm_password = cleaned_data['confirm_password']
        if password != confirm_password:
            raise forms.ValidationError('パスワードが異なります')

    def save(self, commit=False):
        user = super().save(commit=False)
        validate_password(self.cleaned_data['password'], user)
        user.set_password(self.cleaned_data['password'])
        user.save()
        return user
ユーザー情報変更処理

models.py
特になし


views.py

@login_required
def user_edit(request):
    user_edit_form = forms.UserEditForm(request.POST or None, request.FILES or None, instance=request.user)
    if user_edit_form.is_valid():
        messages.success(request, '更新完了しました。')
        user_edit_form.save()
    return render(request, 'accounts/user_edit.html', context={
        'user_edit_form': user_edit_form,
    })


forms.py

class UserEditForm(forms.ModelForm):
    username = forms.CharField(label='名前')
    age = forms.IntegerField(label='年齢', min_value=0)
    email = forms.EmailField(label='メールアドレス')
    picture = forms.FileField(label='写真', required=False)

    class Meta:
        model = Users
        fields = ('username', 'age', 'email', 'picture')

AJAX非同期処理とキャッシュ制御(関数ベースView)

Ajax

画面遷移せずにクライアント側からjQueryを用いてサーバサイドにリクエストを投げてデータをやり取りする技術

クライアント側

$.ajax({
   url : “create_post/”, // 実行するURLを指定
   type : “POST”, // HTTPメソッド
   data : { the_post : $(‘#post-text’).val() }, // 送信するデータ
   success : function(json) { // 実行が成功した場合の処理
   },
   error : function(xhr,errmsg,err) { // 実行が失敗した場合の処理
}
});

サーバーサイド側はJSONを返す処理を記述する

from django.http import JsonResponse
from django.core import serializers


if request.is_ajax:
   # 処理
   json_instance = serializers.serialize(‘json’, [ instance, ]) # jsonに変換する
   return JsonResponse({“instance”: json_instance}, status=200) # レスポンスを返す
   または
   return HttpResponse(
      json.dumps(response_data),
      content_type="application/json"
   )
CACHEの種類

1. Memcached: メモリー上キャッシュ、処理が高速、最も一般的に使われ複数サーバで共有も可能
2. Database: データベースに保存するキャッシュ、取得速度遅いが大容量
3. File system: ファイル上に分割して保存するキャッシュ、速度は遅いが管理が楽
4. Local memory: ローカルPCメモリー上に保存するキャッシュ、デフォルトキャッシュ
5. Dummy: 開発環境用ダミーキャッシュ

Djangoのドキュメント Cache
Django’s cache framework | Django documentation | Django


キャッシュの設定例
setting.py

CACHES = {
  'default': {
    'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
    'LOCATION': [
      '172.19.26.240:11211',
      '172.19.26.242:11212‘,
    ]
  }
}

キャッシュ操作

from django.core.cache.cache import *

cache.set(‘KEY’, ‘VALUE’)
cache.get(‘KEY’, ‘DEFAULT’) # 存在しない場合はデフォルト値が返ってくる
cache.clear()
cache.delete('KEY')
Ajax実装例

JQuery CDNのインポート
jQuery CDN
uncompressedをクリック
スクリプト部分をコピーしてbase.pyのようなHTMLファイルのheadタグ部分にペーストする


Ajaxの処理を記述

base.pyのbodyタグ内にブロックを記述し、使いたいテンプレートで処理を記述する

<body>

{% block javascript %}{% endblock %}

</body>


例: フォームの一時保存機能を追加する場合

テンプレートのフォームコンポーネントにidを付与してscriptブロックにscriptタグで処理を記述する

<form method="POST">
  {% csrf_token %}
  {{ post_comment_form.as_p }}
  <input type="button" value="一時保存" id="save_comment">
  <input type="submit" value="コメント送信">
</form>


{% block javascript %}
<script>
$("#save_comment").click(function(){
    var comment = $("#id_comment").val();
    $.ajax({
        url: "{% url 'boards:save_comment' %}",
        type: "GET",
        data: {comment: comment, theme_id: "{{ theme.id }}"},
        dataType: "json",
        success: function(json){
            if(json.message){
                alert(json.message);
            }
        }
    });
});
</script>
{% endblock %}

urlでセーブ用のViewの処理のエンドポイントを呼ぶ処理を実装しているので、viewの処理も実装する
urls.pyの実装例は省略

キャッシュを設定する処理
views.py

from django.core.cache import cache
from django.http import JsonResponse

def save_comment(request):
    if request.is_ajax:
        comment = request.GET.get('comment')
        theme_id = request.GET.get('theme_id')
        if comment and theme_id:
            cache.set(f'saved_comment-theme_id={theme_id}-user_id={request.user.id}', comment)
            return JsonResponse({'message': '一時保存しました!'})

saved_comment-theme_id={theme_id}-user_id={request.user.id} は長いがただのKEYである

例: キャッシュを取出す処理
views.py

def post_comments(request, theme_id): # フォームにレンダリングするためのデータを渡す処理
    saved_comment = cache.get(f'saved_comment-theme_id={theme_id}-user_id={request.user.id}', '')
    post_comment_form = forms.PostCommentForm(request.POST or None, initial={'comment': saved_comment})

画面読み込み時にレンダリングするためのデータを渡す処理の先頭にキャッシュされたデータを取得する処理を記述しておく
フォームの第二引数 initial にキャッシュの初期値を設定する
また、フォーム画面でデータをサーバー側に保存した場合はcache.deleteを使用してキャッシュデータを削除しておくことも忘れないようにしておく

クラスベースビューを使用した主にログイン処理

Djangoはクラスベースビューを用いる方が一般的で実践的
ただし別のフレームワークでフロントエンド開発をする場合はDjangoREST APIとして使用するため関数ベースビューで記述する方が一般的かもしれない
Djangoで画面まで作成する場合にはクラスベースビューで作成する方が便利だと思われる

以下のテンプレートがある

View 全てのViewの元になるView
TemplateView テンプレートを表示する。ホーム画面など
CreateVIew データベースにデータを挿入するView。データ作成画面
UpdateView データを更新するView
DeleteView データを削除するView
ListView 特定のテーブルのデータ一覧を表示するView
DetailView テーブルのレコードの詳細を表示するView
FormView Formを表示してデータを送信するView
RedirectView リダイレクトを行うView
View

上記のテンプレートでうまく実装できなさそうな処理を記述するときに継承するViewクラス

リクエストに応じた処理を記述する

class MyView(View):
  def get(self, request, **kwargs):
    # GETの場合の処理
  def post(self, request, **kwargs):
    # POSTの場合の処理
リダイレクトビュー(RedirectView)

urls.pyに直接記述

from django.views.generic.base import RedirectView

urlpatterns = [
  path('/search/<term>/', RedirectView.as_view(url='https://google.co.jp/?q=%(term)s')),
]
SuccessMessageMixin

データ更新削除時等のメッセージ表示に便利なクラス
Pythonは複数クラスの継承ができるため作成するViewの2つ目のクラスとしてSuccessMessageMixinを継承する
例 views.py

class BookUpdateView(UpdateView, SuccessMessageMixin):
  model = Books
  success_message # 成功時メッセージ(静的)
  def get_success_message(self, cleaned_data): # 成功時メッセージ(動的


そのほかのテンプレートビューは使用する際に配布資料参照のこと

ログイン処理(クラスベースView)

ログインログアウト処理

クラスベースのログインビューが存在するためそれを利用する

from django.views.generic import View

class LoginView(View):
  def post(self, request, *args, **kwargs):
    username = request.POST[‘username’] # usernameを取得
    password = request.POST['password’] # passwordを取得
    user = authenticate(username=username, password=password) # userが存在するか確認
    if user is not None:
      if user.is_active:
         login(request, user) # ログイン処理
    return render(request, "index.html")


class LogoutView(View):
  def get(self, request, *args, **kwargs):
    logout(request) # ログアウト処理
    return HttpResponseRedirect(settings.LOGIN_URL)
ログイン制約

LoginRequiredMixin
ログインユーザーのみViewにアクセスできるように制限するクラス
使用するviewに継承する

class MyView(LoginRequiredMixin, View): # ログインが必要なViewに継承させる
  login_url = '/login/'
  redirect_field_name = 'redirect_to’

@method_decorator(login_required)
アノテーションでもログイン制約を付与することができる
Viewの中の一部のメソッドのみログイン制約をつけたい場合に使用できる

class ProtectedView(TemplateView):
  @method_decorator(login_required)
  def dispatch(self, request, *args, **kwargs):
ログインセッション時間について

セッション時間はデフォルトで2週間となっている
変更する場合はsettingにセッション情報を追記する
プログラム中で個別にセッション時間を指定したい場合には request.session.set_expiry(value) を使用する

settings.py

SESSION_COOKIE_AGE: セッションの保存時間(秒)

https://docs.djangoproject.com/ja/3.1/ref/settings/#std:setting-SESSION_COOKIE_AGE

request.session.set_expiry(value): セッションの保存時間を引数の時間(秒)に変更する

※なお、引数が0の場合、ブラウザを閉じるとセッションが破棄される
value に datetime または timedelta オブジェクトを指定すると指定された日時に破棄される
https://docs.djangoproject.com/ja/3.1/topics/http/sessions/#django.contrib.sessions.backends.base.SessionBase.set_expiry

ログイン実装例(クラスベース)
例: ユーザー登録
class RegistUserView(CreateView):
    template_name = 'regist.html'
    form_class = RegistForm
例: ログイン
class UserLoginView(LoginView):
    template_name = 'user_login.html'
    authentication_form = UserLoginForm

    def form_valid(self, form):
        remember = form.cleaned_data['remember']
        if remember:
            self.request.session.set_expiry(1200000)
        return super().form_valid(form)

def form_validはセッション情報保持ボタンを押すとログイン情報を保持することができるような処理にしている
※講座の中でデフォルトのセッション時間を5秒にしているため
実際の実装の場面ではform_validの部分は省いて実装するケースがほとんどだと思う


FormViewで定義する例

class UserLoginView(FormView):
    template_name = 'user_login.html'
    form_class = UserLoginForm

    def post(self, request, *args, **kwargs):
        email = request.POST['email']
        password = request.POST['password']
        user = authenticate(email=email, password=password)
        next_url = request.POST['next']
        if user is not None and user.is_active:
            login(request, user)
        if next_url:
            return redirect(next_url)
        return redirect('accounts:home')
例: ログアウト
class UserLogoutView(LogoutView):
    pass

Viewで定義する例

class UserLogoutView(View):
    
    def get(self, request, *args, **kwargs):
        logout(request)
        return redirect('accounts:user_login')
例: ログイン制約
# @method_decorator(login_required, name='dispatch')
class UserView(LoginRequiredMixin, TemplateView):
    template_name = 'user.html'

    # @method_decorator(login_required)
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

MiddleWareを利用したログ出力の実装(Pythonの基本ログ出力方法)

ログ出力をする意義

以下の理由にて実務で必須

  • エラー解析
  • パフォーマンス解析(レスポンスタイム)
ログレベル

上から順位重大

  • critical 重大エラー
  • error エラー
  • warning 警告
  • info 情報
  • debug デバッグ
出力方法

loggingモジュール(デフォルトでログレベルはwarning)

import logging

# ログの出力
logging.critical(‘エラー内容')
.. rtc
ファイルへの出力
logging.basicConfig(filename=‘app.log’, filemode=‘w’) 
よく使うテクニック
ogging.error(‘%s raised error’, name) # 変数をログに出力
logging.error(f‘{name} raised error’) # 変数をログに出力(3.6以降)
logging.error(f‘{name=} raised error’) # 変数をログに出力(3.8以降)
logging.error(“”, exc_info=True) #スタックトレースの出力

logging.basicConfig(format=‘%(asctime)s-%(process)s-%(levelname)s-%(message)s’) # 出力するログのフォーマットの設定%(asctime): 出力時間、%(process): プロセスID、%(levelname): ログレベル、%(message):メッセージ
datefmt: 時刻のフォーマットを指定

スタックトレースは特によく使う模様
原因特定するために何行目のログ情報かの情報がとても有益となる

ログの一般的な使い方 tryとかで使う

try:
  method()
except Exception as e
  logggin.error(e, exc_info=True)

logging.error(“”, exc_info=True) #スタックトレースの出力
ロガーのインスタンス
logger = logging.getLogger(__name__)

__name__はファイル名を差し、ロガーの識別子となる
使用を推奨されている

handlerを設定する

logger.setLevel(logging.DEBUG) # ログレベルのデフォルトはwarning
ロガーの情報を設定ファイルに記述して使用する方法

confフォルダを作成して、設定ファイルを logger.conf 作る

[loggers] # loggerの一覧を設定
[handlers] # Handlerの一覧を設定
[formatters] # Formatterの一覧を設定
[logger_${logger名}] # 各loggerの設定
[handler_${handler名}] # handlerの設定
[formatter_${formatter名}] # 各formatterの設定

logger.conf 例

[loggers]
keys=root

[handlers]
keys=consolehandler, filehandler

[formatters]
keys=sampleformatter

[logger_root]
level=DEBUG
handlers=consolehandler, filehandler

[handler_consolehandler]
class_StreamHandler
level=INFO
formatter=samleformatter
args=(sys.stdout,)

[handler_filehandler]
class=FileHandler
level=Error
formatter=samleformatter
args=['logs/app.log', 'a', 'utf-8']

[formatter_sampleformatter]
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'

ログのインスタンスを使用してログを出力させる
logging.py

import logging
import logging.config

logging.config.fileConfig(fname='conf/logger.conf')

logger = logging.getLogger(__name__)

logger.debug('')
logger.info('')
logger.warning('')
logger.error('')
logger.critical('')

logsフォルダを作ってログをファイルとして出力させる
今回はinfo以上のログが出力される

複数のloggerを設定して別々のログを出力させることも可能
confファイル

[loggers]
keys=root, samplelogger

[handlers]
keys=consolehandler, filehandler, sampleconsolehandler

[formatters]
keys=sampleformatter

[logger_root]
level=DEBUG
handlers=consolehandler, filehandler

[logger_samplelogger]
level=DEBUG
handlers=filehandler, sampleconsolehandler
qualname=samplelogger
propagate=0

[handler_consolehandler]
class=StreamHandler
level=INFO
formatter=sampleformatter
args=(sys.stdout,)

[handler_filehandler]
class=FileHandler
level=ERROR
formatter=sampleformatter
args=['logs/app.log', 'a', 'utf-8']

[handler_sampleconsolehandler]
class=StreamHandler
level=DEBUG
formatter=sampleformatter
args=(sys.stdout,)

[formatter_sampleformatter]
format=%(asctime)s-%(name)s-%(levelname)s-%(message)s

logging.pyファイル

import logging
import logging.config

logging.config.fileConfig(fname='conf/logger.conf')

logger = logging.getLogger(__name__)

logger.debug('デバッグログ')
logger.info('インフォログ')
logger.warning('ワーニングログ')
logger.error('エラーログ')
logger.critical('クリティカルログ')


logger = logging.getLogger('samplelogger')
logger.debug('デバッグログ')
logger.info('インフォログ')
logger.warning('ワーニングログ')
logger.error('エラーログ')
logger.critical('クリティカルログ')
ローテーティングファイルハンドラーを利用する

一定の条件を設定して、別ファイルにログを新たに切り出す設定
管理上見やすくしたり1ファイルのファイルサイズの肥大化を防ぐことができる

例: logger.confファイルに以下を追記

[handler_fileHandler]
class=handlers.RotatingFileHandler
level=INFO
formatter=sampleFormatter
args=('./logs/log.out', when='S', interval=10, backupCount=5, encoding=‘utf-8’, 
maxBytes=1000)

時間によるローテーションを設定したい場合は以下

[handler_fileHandler]
class=handlers.TimedRotatingFileHandler
level=INFO
formatter=simpleFormatter
args=(‘logger.log’,when=’D’,interval=1,backupCount=3,encoding=‘utf-8’)

MiddleWareを利用したログ出力の実装(Djangoの基本ログ出力方法)

上記はpythonの基本のログ出力方法
以下ではDjangoに設定する方法

Djangoログ出力 setting.py

Djangoのログ出力は setting.py に記述する
ロギング | Django ドキュメント | Django

setting.py

LOGGING = {
  ‘version’: 1, # ロガーバージョン通常は1
  ‘disable_existing_loggers’: False, # デフォルトログの無効化通常はFalse
  'formatters': {
    : # フォーマッターの一覧を記載
  },
  'handlers': {
    : # ハンドラーの一覧を記載
  },
  'loggers': {
    : # ロガーの一覧を記載
  }
}

setting.py 例

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'simple': {
            'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)s] %(message)s',
        }
    },
    'handlers': {
        'console_handler': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
            'formatter': 'simple',
        },
        'timed_file_handler': {
            'level': 'INFO',
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': os.path.join('logs', 'application.log'),
            'when': 'S',
            'interval': 10,
            'backupCount': 10,
            'formatter': 'simple',
            'encoding': 'utf-8',
            'delay': True,
        },
        'timed_error_handler': {
            'level': 'ERROR',
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': os.path.join('logs', 'application_error.log'),
            'when': 'S',
            'interval': 10,
            'backupCount': 10,
            'formatter': 'simple',
            'encoding': 'utf-8',
            'delay': True,
        },
        'timed_performance_handler': {
            'level': 'INFO',
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': os.path.join('logs', 'application_performance.log'),
            'when': 'S',
            'interval': 10,
            'backupCount': 10,
            'formatter': 'simple',
            'encoding': 'utf-8',
            'delay': True,
        }
    },
    'loggers': {
        'application-logger': {
            'handlers': ['console_handler', 'timed_file_handler'],
            'level': 'DEBUG',
            'propagate': False,
        },
        'error-logger': {
            'handlers': ['timed_error_handler'],
            'level': 'ERROR',
            'propagate': False,
        },
        'performance-logger': {
            'handlers': ['timed_performance_handler'],
            'level': 'INFO',
            'propagate': False,
        }
    }
}


ログの出力の処理
views.pyやmodels.pyなど

import logging

# 前処理
application_logger = logging.getLogger('application-logger')
error_logger = logging.getLogger('error-logger')


# メソッド内で記述する
application_logger.debug('エラーメッセージ')
error_logger.error('エラーメッセージ')
ミドルウェアを利用する

リクエスト・レスポンス処理にフックを加えて入力と出力の値を置き換えるフレームワークのことをミドルウェアという
ミドルウェア (Middleware) | Django ドキュメント | Django

django.utils.deprecation.MiddlewareMixinを継承する

Djangoのビューを呼び出す前に実行される処理を記述する場合は
def process_view(self, request, view_func, view_args, view_kwargs):のメソッドを記述する
def process_viewのreturnは通常Noneを返す

ビューで例外が発生した場合に実行される処理を記述する場合は
def process_exception(self, request, exception) のメソッドを追記する
returnはNoneとHttpResponseオブジェクトを返す
Noneをreturnする場合は他のエラーハンドリング処理を記述する
ミドルウェアの定義の逆順に実行される(setting.pyのMIDDLEWAREの下から順番に実行される)

ビューの実行後に呼び出される場合
def process_template_response(self, request, response):


middleware.pyを記述してsetting.pyに記述する

middleware.py 例(プロジェクトフォルダに作成)

# middleware.py
import logging
from django.utils.deprecation import MiddlewareMixin
import time

application_logger = logging.getLogger('application-logger')
error_logger = logging.getLogger('error-logger')
performance_logger = logging.getLogger('performance-logger')

class MyMiddleware(MiddlewareMixin):

    def process_view(self, request, view_func, view_args, view_kwargs): # viewを呼び出す前に実行
        application_logger.info(request.get_full_path())
        
    def process_exception(self, request, exception):
        error_logger.error(exception, exc_info=True)


class PerformanceMiddlware(MiddlewareMixin):

    def process_view(self, request, view_func, view_args, view_kwargs):
        start_time = time.time()
        request.start_time = start_time

    def process_template_response(self, request, response):
        response_time = time.time() - request.start_time
        performance_logger.info(f'{request.get_full_path()}: {response_time}s')
        return response

setting.py 例

MIDDLEWARE = [
    'class_based_view.middleware.PerformanceMiddlware', # リクエストでは最初・レスポンスでは最後に実行される
    # ...
    'class_based_view.middleware.MyMiddleware'  # リクエストでは最後・レスポンスでは最初に実行される
]

OAuth認証の実装

f:id:shinseidaiki:20211220233228p:plain

offlineアクセスのオフラインとはネットワークにつながっていないということではなくユーザーがいない状態のことを差す


必要なモジュール

django-allauth

pip install django-allauth
google OAuth認証を試してみる

settings.pyのINSTALLED_APPに以下のものを追加する
settings.py

'django.contrib.sites',
'allauth',
'allauth.account',
'allauth.socialaccount',
‘allauth.socialaccount.providers.google’, # google認証

ログイン処理時に認証で行うクラスにallauthを追加する
settings.py

AUTHENTICATION_BACKENDS = (
  “django.contrib.auth.backends.ModelBackend”, # デフォルトの認証
  "allauth.account.auth_backends.AuthenticationBackend", # allauthの認証
)

settings.pyの下部に記載

SITE_ID = 1 # django_site テーブル上の認証に用いるサイトの指定 通常1(django_siteは最初のマイグレーションで作成されるテーブル)
ACCOUNT_EMAIL_REQUIRED = True # 認証にメールアドレスが必要か(デフォルトはFalse)
ACCOUNT_USERNAME_REQUIRED = False # 認証にユーザ名が必要か(デフォルトはTrue)
ACCOUNT_AUTHENTICATION_METHOD = ‘email’ # 認証に利用する要素(email or username or 
username_email)
SOCIALACCOUNT_PROVIDERS = { # プロバイダーごとの設定
  'google': {
    ‘SCOPE‘: [ # Google APIで何を取得するか
      'profile',
      'email',
    ],
    'AUTH_PARAMS': {
      ‘access_type’: ‘onine’, #バッチ処理の場合はオフラインで利用する
}}}

アクセスタイプのofflineはネットにつながっていないことでなくユーザーがいない状態例えばバッチetc. のこと

SOCIALACCOUNT_PROVIDERSの中身は以下のドキュメントのProviderの項目を見て記述する
https://django-allauth.readthedocs.io/en/latest/installation.html


google Cloud platformにプロジェクトを登録する

1
google cloud platformで検索してgcpのトップページにアクセス

2
利用規約に同意

3
コンソール画面でプロジェクトの選択>新しいプロジェクト
プロジェクト名と場所(組織なし)を入力して作成

4
プロジェクトを選択をおし
APIサービス>OAuth同意画面
UserTypeを外部に選択して作成

5
アプリ名には先ほどのプロジェクト名を
ユーザーサポート名には適当なメールアドレスを入力する
デベロッパーの連絡先情報は適当なメールアドレスを入力する
保存して次へ

6
スコープはそのままで保存して次へ
テストユーザーもそのままで保存して次へ
概要もそのままでダッシュボードに戻る

7
APIサービス>認証情報作成>認証情報を作成>OAuthクライアントIDを選択
アプリケーションの種類:ウェブアプリケーション
名前:ウェブクライアント1
承認済のJavaScript生成元にローカルのURLを記載
http://127.0.0.1:8000
承認済のリダイレクトURIにも以下のようなURIを記載
http://127.0.0.1:8000/oauth_accounts/google/login/callback/

作成

8
作成されたクライアントIDとシークレットキーをメモなどどこかに保存する

OAuth認証実装 Djangoソースコード側の手順

エディタに戻ってDjangoソースコードを編集していく
マイグレーションを実行する

py manage.py migrations 

これでマイグレーションの履歴を見るとsocialaccount_○○というOAuthで使用されるDBテーブルが4つほど作成されていることがわかる

スーパーユーザーを作成する

py manage.py createsuperuser 

作成した管理者ユーザーで管理者画面にログインする
SOCIAL ACCOUNTSの中に
social accounts
social application tokens
social applications
テーブルがあることがある
この中にOAuthの設定を記載していく

まず、Siteのテーブルの中にexampleのドメイン名があるがこれを127.0.0.1に変更する

次にSocial Applicationsをクリックして以下を追加する
Provider: google
name: OAuth App
クライアントIDは先ほどコピーした値
シークレットキーも先ほどコピーした値
Keyは空のまま
Sitesの127.0.0.1を左から右に移動させる

これで保存する
こうすることでGoogle認証でログインすることが可能となる

この後、実際にOathでログインしたユーザーはsocial accountsテーブルにメースアドレスや名前などが追加される

画面遷移を作成する

urls.pyにOauthの画面への遷移を追加する

urls.py

path('oauth_accounts/', include('allauth.urls’))


ユーザーログインのテンプレート画面にgoogleログインのためのリンクを貼り付ける
user_login.html

{% load socialaccount %}
<a href=“{% provider_login_url ‘google’ %}”>googleログイン</a>

例:user_login.html

{% extends 'base.html' %}
{% load socialaccount %}
{% block content %}
<form method="POST">
   {% csrf_token %}
   {{ form.as_p }}
   <input type="hidden" name="next" value="{{ request.GET.next }}">
   <input type="submit" value="ログイン">
</form>
<a href="{% provider_login_url 'google' %}">Googleログイン</a>
{% endblock %}
動作確認

Djangoサーバーを起動してアプリケーションにアクセス
ユーザーログイン画面からgooglerログインを選択して、よく見るグーグル認証の画面に遷移して、その後認証が通って元の画面にリダイレクトされればよい

DBの中身を確認するとSOCIAL ACCOUNTSテーブルの中にログインされたUserのEmailアドレスなどが格納されていることがわかる
Account Userの中身を見るとユーザーのEmailが見れることが確認できれば良い

画面をきれいにする

http://127.0.0.1/oauth_accounts/signup/
などと指定すると、oauthのライブラリが持っている変な画面が表示されてしまう

これを表示させたくないない場合はurls.pyに記述していた箇所を以下のように書き換える

from allauth.socialaccount.providers.google.urls import urlpatterns as google_url

path('oauth_accounts/', include(google_url)),

こうするとピンポイントにgoogleのurlだけを入れることができる

END

バッチ処理の実装・運用

ドキュメント
https://docs.djangoproject.com/ja/3.1/howto/custom-management-commands/

基本利用方法

アプリケーションフォルダの直下にmanagementフォルダを作成して、commandsフォルダを作成してその中にコマンド(sample.py)を配置

app\management\commands\sample.py

from django.core.management.base import BaseCommand

class Command(BaseCommand):
    
    def handle(self, *args, **options):
        print("バッチ実行")

このバッチファイルを実行する場合は以下のコマンドで実行できる

py manage.py sample

storesアプリケーションフォルダにも同じようなフォルダ階層でバッチファイルを作成することもできる
stores\management\commands\sample.py

from django.core.management.base import BaseCommand

class Command(BaseCommand):

    def handle(self, *args, **options):
        print('storeのバッチを実行')


なお別のアプリケーションに同じ名前のバッチファイルを作成した場合は、setting.pyに記載したアプリの上から順番に実行される(sample.pyが二つあった場合は、py manage.py sampleでは先に登録されているアプリ(app)の方のsample.pyが実行される)

引数の追加

add_argments: コマンド内で利用する引数を追加する

app/management/sample.py

class Command(BaseCommand):
  def add_arguments(self, parser):
    parser.add_argument(‘first’) # 第1引数をキーfirstに格納する
    parser.add_argument(‘second’) # 第2引数をキーsecondに格納する
    parser.add_argument(‘--option1’) # option1で指定した引数をキーoption1に格納する

  def handler(self, *args, **options):
    print(f’{options[“first”]}’) # add_argumentsで格納した値を取り出す
    print(f’{options[“option1”]}’) # add_argumentsで格納した値を取り出す
    • を使うとマップ型のkeyとして定義することができる

呼び出す場合は必ず引数を必要となり、以下のようなコマンドで呼び出すことができる

python manage.py sample A(第1引数) B(第2引数) --option1 C(option1に対する値)
コマンド内で利用する引数の追加

type: 数値型(int)、文字列型(str)で指定する
help: 説明文の追加 「python manage.py help コマンド名」 で使用できる
python manage.py help コマンド名で表示することができる
default: デフォルトの値(格納する引数が存在する場合に指定した値が格納される)
nargs: 格納する値の数を指定してリスト型で格納。呼び出す際はコマンドの引数に配列の数だけ引数を列挙して渡して実行する
action: store_true(引数が存在する場合はTrue)、store_false (引数が存在する場合はFalse)が格納される
choices: 格納できる値の一覧を記述する

例: sapmle.py

from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help='ユーザ情報を表示するバッチです'

    def add_arguments(self, parser):
        parser.add_argument('name', type=str, help='名前') # 1引数
        parser.add_argument('age', type=int) # 2引数
        parser.add_argument('--birthday', default='2020-01-01')
        parser.add_argument('three_words', nargs=3)
        parser.add_argument('--active', action='store_true') # 引数--activeを指定した場合にTrueを格納する
        parser.add_argument('--color', choices=['Blue', 'Red', 'Yellow']) # 例えばBlueを指定すると以下のハンドルの対応する青が表示される

    def handle(self, *args, **options):
        name = options['name']
        age = options['age']
        birthday = options['birthday']
        three_words = options['three_words']
        active = options['active']
        print(
            f'name = {name}, age = {age}, birthday = {birthday}, three_words = {three_words}'
        )
        print(active)
        color = options['color']
        if color == 'Blue':
            print('青')
        elif color == 'Red':
            print('赤')
        elif color == 'Yellow':
            print('黄')
応用

Ordersモデルで定義されたDBの中のOrdersテーブルのデータをファイルに出力するバッチファイルの作成
例: stores/management/commands/export_orders.py

from django.core.management.base import BaseCommand
from stores.models import Orders
from ecsite_project.settings import BASE_DIR
from datetime import datetime
import os
import csv

class Command(BaseCommand):

    def add_arguments(self, parser):
        parser.add_argument('--user_id', default='all')

    def handle(self, *args, **options):
        orders = Orders.objects
        user_id = options['user_id']
        if user_id == 'all':
            orders = orders.all()
        else:
            orders = orders.filter(user_id=user_id)
        file_path = os.path.join(BASE_DIR, 'output', 'orders', f'orders_{datetime.now().strftime("%Y%m%d%H%M%S")}_{user_id}')
        with open(file_path, mode='w', newline='\n', encoding='utf-8') as csvfile:
            fieldnames = ['id', 'user', 'address', 'total_price']
            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
            writer.writeheader()
            for order in orders:
                writer.writerow({
                    'id': order.id,
                    'user': order.user,
                    'address': order.address,
                    'total_price': order.total_price,
                })
||

こうすることで日繰りでファイルにデータをレポートとして出力させるバッチにしたりすることができる


** メッセージフレームワーク

*** messages.html を作成して、base.htmlに配置してすべての画面でメッセージを出力できるようにしておく

まずはsnippetsに以下のように {% for msg in message %} を使用したメッセージ出力用の画面を作成しておく
messages.html
>|html|
{% for msg in messages %}
<nav class="py-2 mb-0 {{ msg.tags }}">
  <div class="container">
    {{ msg }}
  </div>
</nav>
{% endfor %}

この中で

settings.pyにメッセージの重要度ごとのCSS設定を記述しておく

settings.pyに以下のような記述をしておく

from django.contrib import messages

# messages
MESSAGE_TAGS = {
    messages.ERROR: 'rounded-0 alert alert-danger',
    messages.WARNING: 'rounded-0 alert alert-warning',
    messages.SUCCESS: 'rounded-0 alert alert-success',
    messages.INFO: 'rounded-0 alert alert-info',
    messages.DEBUG: 'rounded-0 alert alert-secondary',
}
メッセージフレームワークをビューの処理の成功時・失敗時のメッセージ制御に使用する

ビューの処理毎にmessagesを付け加えることでメッセージを表示させる

成功した場合の例

from django.contrib import messages

class View(CreateView):
  def form_valid(self, form): 
    messages.success(self.request, '成功しました')
    return super().form_valid(form)

失敗した場合の例

from django.contrib import messages

class Login(LoginView):
    def form_invalid(self, form):
        messages.error(self.request, '失敗しました')
        return super().form_invalid(form)

これで処理毎に想定したメッセージが表示されていればよい

コンテキストプロセッサー

コンテキストプロセッサーとは動的なデータを表示させようと思うと画面毎に個別にViewsで画面にコンテキストを渡す必要があるが、あらゆる画面で共通して表示させたい情報があった場合はview毎に明示して記載すると面倒でかつメンテナンス性も落ちてしまうため、コンテキストプロセッサーとは記載しておけば、全ての画面にコンテキストとしてデータを渡す機能である

コンテキストプロセッサーのファイルを作成する

プロジェクト(config)フォルダの中にcustom_context_processors.pyとして作成することが一般的な模様

例 config/custom_context_processors.py

from django.conf import settings
from base.models import Item
 
def base(request):
    return {
        'TITLE': settings.TITLE,
        'POPULAR_ITEMS': items.order_by('-sold_count')
    }

上はタイトルや人気のアイテムをすべての画面にコンテキストとして渡す処理となっており、こうすることでどの画面でも人気商品をいつでも表示できるようになる
ちなみにコンテキストプロセッサーの変数は大文字で記載するのが慣習なのかもしれない

settings.pyにコンテキストプロセッサーを使用する設定を追記する

主にTEMPLATESにcontext_processorsの項目に先ほど作成したファイルのパスを記述する
configはプロジェクト名になるため、別の名前になっている場合はその名前に合わせる

settings.py

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'config.custom_context_processors.base',  # 追記
            ],
        },
    },
]

# custom_context_processors
TITLE = 'VegeKet'
テンプレートにおけるコンテキストプロセッサの表示

通常のテンプレートに変数を表示させるのと同じようにブラケット記法で {{ TITLE }} 表示させることができる
例: header.html

<nav>
    <a class="py-2 text-white" href="/">{{ TITLE }}<!--VegeKet--></a>
</nav>
TIPS! バッジの作成と表示

Webコンポーネントで言及されるバッジとは、Webアプリの通知(ベルのマーク)などで何件通知があるかなどを表示してくれるコンポーネントのことである

ヘッダーなどに設定する
コンテキストプロセッサーに登録した変数を使用すると便利
header.html

<a class="py-2 d-none d-md-inline-block text-white" href="/cart/">Cart
      {% if request.session.cart and request.session.cart.items|length != 0 %}
      <span class="badge badge-danger">{{request.session.cart.items|length}}</span>
      {% endif %}
    </a>

表示の方法としてはクラスでbadgeを指定するだけでよい(Bootstrapの場合)
今回はコンテキストプロセッサーではなくセッションのcartデータから値を取得している

TIPS! テンプレート構文を使用したときにHTMLのインデントがずれてしまうとき

特にテンプレート内で比較構文を入れるとこれが起きやすい
例 {% if compare < 10 %} の < がHTMLのタグと認識されることが起きたりする
これは文法的にも問題はなく対処法も特にないので、見過ごすしかない模様

Tipa! 404 Not Foundページの実装

templatesフォルダ直下に 404.html を作成して、settings.pyの
DEBUG=False
ALLOWED_HOSTS = ['127.0.0.1']
を設定すると、404.htmlの内容が表示されるようになる
DEBUGがTrueだったり、ALLOWED_HOSTSが設定されていなかったりすると、エラーが返ってくる

500.htmlなど他のステータスコードも同様に実装することができる

404.html

<!--省略-->

<div>404 Not Found アクセスしようとしたページは見つかりません</div>

<!--省略-->

10万円の給付のクーポン配布案に関して思うこと

オリンピックでは湯水のように予算執行されたのに10万円の給付になるとけちけちしてる今の政治の状況見るとほんとなんのために税金納めてるんだろうかと思ってしまう今日このごろです

ただでさえコロナの経済自粛によるマイナス成長で普通に生きてても30万円くらいは収入が実質減ってる状況のなかで、10万円給付って額でも少ないってなるのに、一律でやらずに、挙句の果てには半分はクーポンとか言い出しているのを見ると、もはや悪意しか感じませんね

さすがに唖然です

そんなにクーポンが好きなら公務員の給料の半分をクーポンにしたらいいのではないでしょうか??

特に官僚の方々は心が病んでそうな人が多そうなので、カウンセリング治療費の半額クーポンとかにすれば実際わりと使われる気がします

以上、お気持ち表明でした

反捕鯨(捕鯨反対)運動に関する考察・陰謀論

はじめに

そういえば昔シー〇ェパードとかいう反捕鯨団体がありましたが、最近あまり名前を聞かなくなりましたね

10年くらい前は活発でしたが、最近はおとなしい印象です

何かあったのでしょうか?

これは私の勝手な推論ですが、反捕鯨運動は某国の政治的な陰謀だったのではないかと思っています
以下で、このような考えに至る根拠を元に一つ一つ考察していこうと思います

以下の話は全て推測です
勉強不足による勘違いを元に論理展開している場合があるため全く的外れな話を展開している可能性があります
ご了承ください

捕鯨運動とは

捕鯨運動に関する見方は人によってさまざまだと思うので、前提として今回は私の浅い知識で恐縮ですが反捕鯨運動について以下のように定義しようと思います
捕鯨運動とは捕鯨国に対して動物愛護の観点から”愛くるしい”存在のクジラを捕獲するとはけしからんという趣旨の主張を繰り広げる運動です
捕鯨運動で最も問題となっているのは意見を主張するためには過激な破壊行動も厭わない点でした
某国ではテロリスト認定されていた気がします
捕鯨が盛んな日本に対して特に攻撃が厳しく、海にはほかの海洋生物もいるはずなのにとりわけクジラの保護のみにこだわるという偏った(と思わざるを得ない)動物愛護の主張が繰り広げられていました
イルカなども保護対象だった気がしますが、ことクジラに対しては破壊行動を含むとりわけ過激な行動に出ていた気がします
気でも狂ったのかと思えるほど、異常にクジラに固執した主張を展開していた運動だったと記憶しています

このような完全に日本をターゲットにしたかのような反捕鯨運動(10年前くらい)に私は当時若いながらもとても違和感を持っていました
ずっと違和感を感じていたのですが、現在の世界情勢を眺めていてふと、これはクジラの保護という名目でなんらかの別の思惑を達成するために行われた政治的な陰謀なのではないのかと思うようになりました


結論を申しますと、私は反捕鯨運動とは中国が太平洋地域の海洋戦略を展開させていくにあたって用いられる潜水艦を日本の捕鯨探査船が用いるソナーに探知されないために、当時経済的友好関係だったオーストラリアに何らかの政治的取引を持ち掛けて行われた中国の海洋戦略だったのではないのかと考えております

どうしてそう思えるのか、以下で考察していきます


日本の捕鯨船は優秀

そもそも日本の捕鯨船国際法を順守して、法的には何ら問題のない活動を行っており、日本での捕鯨は少なくとも江戸時代まで遡れるなど、歴史的に見ても古くから行われてきた文化だといえます
そんななか10数年ほど前から急に反捕鯨団体の活動が活発になりました
長い歴史がある捕鯨ですが、なぜ今まで平穏に行われてきた捕鯨活動が、突如として非難されることになったのでしょう?
人々の間で動物保護の考え方が根付いてきたからでしょうか?
確かにそうかもしれません
しかしその答えでみなさんは納得がいくでしょうか?
…いいえ私は納得できません
なぜなら動物愛護を語るのであればそのほかの生物に対しても平等に過激な主張を繰り広げないと辻褄があわないからです
クジラにこだわる理由が説明できません
このようなことから理由は他にあるだろうと推測する方が自然であると思います

では、なぜクジラなのか?
私はそのカギは日本の捕鯨船にあると思っています
捕鯨船は普通クジラを見つけるためにソナー(音響探知機)を搭載しているのですが、クジラというと大きくてもせいぜい10mくらいの体高ですので、だだっ広い海の中に潜むクジラを発見しようとするとかなりの精度を要求されます

そのため通常は群れになっているクジラだったり、捕鯨船の近くまで寄ってきたクジラを発見するのは普通みたいですが、日本の捕鯨船は一頭単位で識別できるレベルの精度を有しているらしいです
すごい技術ですね

ところで音響探知技術はそもそも潜水艦探査のために発展した軍事技術であり、現代においてもその役割は変わっていません
そして日本の捕鯨船のソナーはどうやらものすごい技術水準のソナーを使用しているらしく、確かにクジラ一頭を識別できる精度があるならクジラよりも大きな潜水艦を見つけることは造作もないと考えられます
ゆえに日本の捕鯨船のソナーはいつでも軍事転用ができるレベルに達しており、平和利用の形をとりつつ軍事転用できる技術を磨いているという点では、意外と日本もしたたかなんだなと思える事例だと思います
逆に日本が反捕鯨に対して一歩も引く気がない理由もここにあるのだろうと思います

このように捕鯨は軍事にかなり密接な関係にあって、とにもかくにも日本の捕鯨船は優秀であるということがわかります
捕鯨船は実はすごいんですね

ところでここで、よその国の視点に立ってみましょう
例えばあなたの国では海洋進出をしたいと思っています
そうすると偵察などいろいろな目的でこっそりと相手側の様子をうかがいたいと思うはずです
そんなときに役立つのが潜水艦ですね
そんなときに相手側の国がとっても有能なソナーを使って捕鯨活動をしているとどうでしょうか?
…とても邪魔ですね
うっかり見つかってしまう可能性がありますね

そうですね
もしあなたがそういった海洋進出国の気持ちに立てば、何とかして捕鯨をやめさせたいと思うはずです

これが「反捕鯨」という、クジラだけを特別視するイデオロギーの正体だと私は考えます

中国とオーストラリアの関係

唐突ですがここでオーストラリアという国を話題に挙げます
なぜオーストラリア?そして中国?と思う方もいるかもしれません
意外かもしれませんが実はオーストラリアという国は経済的に中国にものすごく依存しています
以下が貿易収支です

貿易総額 7,969億豪ドル (1)中国30.7% (2)米国9.1% (3)日本8.3%
輸出 4,361億豪ドル (1)中国36.4% (2)日本10.7% (3)米国6.3%
輸入 3,608億豪ドル (1)中国23.9% (2)米国12.6% (3)日本5.5%

(2020年、財・サービス、出典:外務貿易省統計)

近年ではオーストラリアの中国離れが進行していますが、数年前までは両者オトモダチというほど友好関係にありました

オーストラリアが中国に経済的に依存し始めたのは数十年前(2005年ごろ)からでした
そしてここからが面白い点ですが、シー〇〇パードが活発になり始めた時期もちょうどこのころだと思います
そしてシー〇〇パードの本部はオーストラリアにあります

これらの点を加味すると、シー〇〇パードは中国から資金援助を受けて活動していたのではないでしょうか?
聞くところによると、シー〇〇パードの資金源は慈善事業者の寄付で賄っていたそうですが(ハリウッドスターなどは有名になってから寄付、それも一時的なものだと思われる)、なんらかの政治的な思惑なしに、純粋な慈善事業者の寄付だけで世界を騒がせるほどの活動ができるとは思えません
とっくにつぶれていると思います
それでもいまだに組織として残っているのは、なんらかのバックがあるからだと思わない方が不自然です

私の推測ですが、大方、中国とオーストラリア政府でなんらかの取引があり、環境保護活動をしていたシー〇〇パードに目をつけて手駒として利用したのではないかと思います
このような見方が一番すんなり来るのではないでしょうか?

そして最近、シー〇〇パードの活動が下火になっているのも中国とオーストラリアの関係が悪化し始めた時期に一致するように思いませんか?
ちなみに中国とオーストラリアの関係悪化は2~3年前ごろから顕著になっています



以上、かつて世間を騒がせていた某団体についての考察でした
おそらくですが、たぶんこの某団体のニュースを聞いてるたびにみなさんもなんか違和感を感じていたと思います
ここまで読んでくれたみなさんがかつて感じたもやもやした気持ちをこの考察を見て少しでもスッキリできたら幸いです


終わりに

そういえば反捕鯨を主張する国は最近始まったわけではなく1950年くらいからあるみたいですね
このことから反捕鯨という概念は中国が生み出した概念というわけではなさそうですね
というよりもむしろ昔からある概念のため中国がそれに便乗したというのが正確なとらえかただと思います
陰謀論的には少しつまらないところではあります


中国が直接反捕鯨を主張せずにわざわざオーストラリアなどの手先を使うという点に関しては確かに疑問のある部分ではありますが、中国が環境保護を積極的に主張してしまうとほかの環境問題に対して対策を取らなければならなくなり結果として自身の首を絞める形になるから主張しないという形をとっているのかと思います


とにもかくにもクジラを食べる食文化がもともとこの国にあることは間違いないですし、クジラはおいしいですし、なにより日本の立場的には捕鯨は文化保護・防衛・食糧確保の観点からなくさない方がいいのは明白ですので、もしこの記事を見て共感をしていただけた方がいれば、なんの恥じることもなくクジラを買って食べましょう

NodeJS + Web RTC ビデオチャットアプリ作成 学習メモ

Web RTCを学ぶ

Udemy講座の学習
最短・最速で作る ビデオチャットアプリケーション NodeJS + WebRTC編

はじめに

Web RTCとは?
参考サイト:

Nodeの準備

Node Serverを準備

ルートディレクトリにserver.jsファイル作成

package.jsonの用意
ターミナルで

npm init

初期設定の質問に対してはすべてEnterを入力

必要なパッケージをインストール

Express

npm i express

Web Socket

npm i socket.io

テンプレートの使用 EJS

npm i ejs

ローカルサーバーの起動 Nodemon

npm i nodemon

package.jsonのscriptに

"dev": "nodemon server,js"

を追記
これでローカルサーバー実行コマンドが追加できる

Expressサーバーの初期実装

Hello Worldの実行

server.jsを以下のように編集

const express = require('express')
const app = express()
const server = require("http").Server(app)
const io = require("socket.io")(server)

server.listen(process.env.PORT || 3030)


app.set("view engine", "ejs")
app.use(express.static("public"))

public, viewsディレクトリを作成して、viewsの中にroom.ejsファイルを作成

room.ejsの作成
!マークでHTMLのテンプレートが作成できるので、Hello Worldを追記する

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    Hello World
</body>
</html>

ルーティングの設定としてserver.jsに以下追記する

app.get("/", (req, res) => {
    res.render("room")
})

サーバー起動

npm run dev

ブラウザでhttp://localhost:3030にアクセスしてHello Worldが実行されていることを確認する

room作成

ランダムなルームを作成したいので、uuidを利用する

npm i uuid

server.jsにuuidを実装して、レンダリングのところをリダイレクトに切り替える
レンダリングするものはルームIDを表示させるようにする

const { v4: uuidV4 } = require("uuid")


app.get("/", (req, res) => {
    // res.render("room")
    res.redirect(`/${uuidV4()}`)
})

app.get("/:room", (req, res) => {
    res.render("room", { roomId: req.params.room });
})

サーバ起動して、urlにランダムなuuidが出力されて、画面はHelloworldになっていることが確認できる

テンプレートでuuidが取得できることを確認する

room.ejs
headタグ内に記述

<script>
        const ROOM_ID = "<%= roomId %>"
        console.log(ROOM_ID)
    </script>

socket ioを利用してデータの送受信を行うシグナリングサーバーの実装

ユーザーが接続されるたびに実行されるイベントハンドラ
server.js

io.on("connection", socket => {
    socket.on("join-room", (roomId, userId) => {
        console.log("roomId", roomId)
        console.log("userId", userId)
    })
})

テンプレート側(room.ejs)

<script src="/socket.io/socket.io.js" defer></script>
<script src="script.js" defer></script>

deferをつけるとjsを読み込んだ後DOMがレンダリングされるまでjsの実行を待ってくれる

テンプレートで記述したスクリプトのソースファイルをpublicに作成する
script.js

const socket = io("/")

const userId = 12345

socket.emit("join-room", ROOM_ID, userId)

ここで一度リロードをするとコンソールにルームIDとユーザーIDが出力される
server.jsのwebsocketのconnectionのjoinルームにルームIDとユーザIDが渡されていることが確認出来たら次の実装

server.js

io.on("connection", socket => {
    socket.on("join-room", (roomId, userId) => {
        // console.log("roomId", roomId)
        // console.log("userId", userId)
        socket.join(roomId)
        // socket.to(roomId).broadcast.emit("user-connected", userId)
        socket.to(roomId)
        socket.broadcast.emit("user-connected", userId)
    })
})

ルームに新しいユーザーが入ってくるとルームに参加できて、そこにいるすべてのユーザーに通知する処理が行えるようにする
コメントアウトしている処理は講座で紹介されたもののエラーが出た処理

script.js

socket.on("user-connected", userId => {
    console.log("userId", userId)
})

動作確認
ブラウザを二つ起動して、後から入ってきたユーザーのidが表示されていることを確認できれば良い


P2Pコネクションの実装

PEER JSを利用する
https://peerjs.com/

room,ejsにスクリプトコードを貼り付ける
deferを追記することを忘れずに

script.jsに以下追記
peerの初期化が終了したタイミングでオープンイベントが発生するので、userIdを受け取ってルームに参加する

const myPeer = new Peer()

myPeer.on("open", userId => {
    socket.emit("join-room", ROOM_ID, userId)
})
// socket.emit("join-room", ROOM_ID, userId)

動作確認
サーバー実行してローカルホストに2つタブを作ってアクセスすると片方のuserIdがもう片方のconsoleに表示される

カメラ表示

script.js

// const userId = 12345
const videoWrap = document.getElementById("video-wrap")
const myVideo = document.createElement("video")
myVideo.muted = true

const addVideoStream = (video, stream) => {
    video.srcObject = stream
    video.addEventListener("loadedmetadata", () => {
        video.play()
    })
    videoWrap.append(video)
}

navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true
}).then((stream) => {
    addVideoStream(myVideo, stream)
})


myVideo,muted = true は自身の声を反響させないように必要
navigatorのthenの返り値に動画データなどが含まれている

room.ejs

<link rel="stylesheet" href="style.css" />

<body>
    <div class="main">
        <div class="main__wrap">
            <div id="video-wrap"></div>
        </div>
    </div>
</body>


style.css

body {
  margin: 0;
  padding: 0;
  background: linear-gradient(top, #074055 0%, #030d10 100%);
  background: -webkit-linear-gradient(top, #074055 0%, #030d10 100%);
  background: -moz-linear-gradient(top, #074055 0%, #030d10 100%);
  background: -o-linear-gradient(top, #074055 0%, #030d10 100%);
  background: linear-gradient(top, #074055 0%, #030d10 100%);
}

#video-wrap {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
}

video {
  height: 360px;
  width: 560px;
  max-width: 100%;
  object-fit: cover;
  margin: 12px;
}
.main {
  height: 100vh;
  display: flex;
}
.main__videos {
  flex-grow: 1;
  flex-direction: column;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 40px;
}

.video__controller {
  text-align: center;
  position: fixed;
  width: 100%;
  bottom: 0;
  left: 0;
  margin-bottom: 24px;
}

.btn__action {
  background: #7fba00;
  border-radius: 50%;
  padding: 8px;
  border: none;
  margin: 20px;
  outline: none;
}

.btn__action.active {
  background: red;
}

.btn__action img {
  width: 38px;
}


動作確認
カメラと音声が表示されていることを確認する

他ユーザーのカメラ情報を表示

script.js

// 新規ユーザーが入ってきた場合の接続送信処理
const connectToNewUser = (userId, stream) => {
    // 送信側
    // 相手のユーザーIDを指定して自身のビデオストリームデータ情報を渡す
    const call = myPeer.call(userId, stream)
    const video = document.createElement("video")
    // 送信したデータに対して相手の応答があった場合に、相手のビデオ情報がstreamとしてcallbackされるためuserVideoStreamとして受け取る
    call.on("stream", userVideoStream => {
        addVideoStream(video, userVideoStream)
    })
    // 送信が切断された場合、応答がなかった場合にビデオを閉じる
    call.on("close", () => {
        video.remove()
    })
}


// ビデオStreamデータの受け取り
navigator.mediaDevices.getUserMedia({
    .......

   // 応答処理
    myPeer.on("call", (call) => {
        call.answer(stream)
    })

    // userが新しく入ってきた時のイベント
    socket.on("user-connected", userId => {
        connectToNewUser(userId, stream)
    })
})

// socket.on("user-connected", userId => {
//     // console.log("userId=", userId)
// })

動作確認
最初に開いた方の画面に新しく開いた方のユーザーの動画が映ることが確認できる

既存ユーザーのカメラ情報を新しくルームに入ってきたユーザーに表示させる

応答処理に処理を追加していく
script.js

// 応答処理
    myPeer.on("call", (call) => {
        call.answer(stream)
        const video = document.createElement("video")
        call.on("stream", userVideoStream => {
            addVideoStream(video, userVideoStream)
        })
    })

動作確認
双方のブラウザ画面に双方の画像が表示されていることが確認できる

接続解除の処理

io.on("connection", (socket) => {内の処理に以下の処理を追加して、接続解除を同じルームのメンバーに対して通知する server.js

io.on("connection", (socket) => {
    socket.on("join-room", (roomId, userId) => {
        ....
        socket.on("disconnect", () => {
            socket.to(roomId)
            socket.broadcast.emit("user-disconnected", userId)
        })
    })
})

script.js

// ユーザーごとにpeer情報を含むメディアコネクション情報を保存するための変数
const peers = {}

const connectToNewUser = (userId, stream) => {
   ......    

    // 他のユーザーのユーザーID情報をpeersに格納
    peers[userId] = call
}

navigator.mediaDevices.getUserMedia({
   ....
}).then((stream) => {
    ......

    // 応答処理
    myPeer.on("call", (call) => {
        ........

        // 自身のユーザーID情報をpeersに格納
        const userId = call.peer
        peers[userId] = call
    })

})


// 接続解除の処理
socket.on("user-disconnected", userId => {
    console.log("userId=", userId)
})

動作確認
退室したユーザーの画像が表示されなくなることを確認できれば良い

制御ボタンの設置

myVideoStream script.js

let myVideoStream

...

navigator.mediaDevices.getUserMedia({
    ...
}).then((stream) => {
    myVideoStream = stream


音声制御 script.js

const muteUnmute = (e) => {
    const enabled = myVideoStream.getAudioTracks()[0].enabled
    if (enabled) {
        e.classList.add("active")
        myVideoStream.getAudioTracks()[0].enabled = false
    } else {
        e.classList.remove("active")
        myVideoStream.getAudioTracks()[0].enabled = true
    }
}

動画制御 script.js
getVideoTracks()[0]がVideoになっただけ

const playStop = (e) => {
    const enabled = myVideoStream.getVideoTracks()[0].enabled
    if (enabled) {
        e.classList.add("active")
        myVideoStream.getVideoTracks()[0].enabled = false
    } else {
        e.classList.remove("active")
        myVideoStream.getVideoTracks()[0].enabled = true
    }
}

接続解除 script.js

const leaveVideo = (e) => {
    socket.disconnect()
    myPeer.disconnect()
    const videos = document.getElementsByTagName("video")
    for (let i = videos.length - 1; i >= 0; --i) {
        videos[i].remove()
    }
}

接続解除通知 script.js

// 接続解除通知
myPeer.on("disconnected", userId => {
    console.log("disconnected=", userId)
})


ボタンの追加 room.ejs

<button type="button" class="btn__action" onclick="playStop(this)">
                    <img src="images/video.png" />
                </button>
                <button type="button" class="btn__action" onclick="muteUnmute(this)">
                    <img src="images/mic.png" />
                </button>
                <button type="button" class="btn__action" onclick="leaveVideo(this)">
                    <img src="images/call.png" />
                </button>

動作確認
ボタンを押して、ビデオオフ、音声オフ、退室ができることを確認する
最初のユーザーを先に退室させると相手側が真っ暗になるバグはあるが気にしないことにする

デプロイ

今回はパス
Herokuにアップロードする方法は過去の記事参照

Nextjs テスト実装方法 基本編勉強

Reactの勉強にいつもお世話になっているこの方の講義手順のメモです(個人メモです)
https://github.com/GomaGoma676/nextjs-testing

Tips!! スタイルについて

スタイルについてののドキュメント
Tailwind CSS - Rapidly build modern websites without ever leaving your HTML.

チートシート
Tailwind CSS Cheat Sheet

ホバーしたときに背景が当たっていることを示せる
hover:bg-gray-700

テスト準備

以下手順でエラーが出る場合はこれが原因の可能性がある

/**
* @jest-environment jsdom
*/

1. プロジェクト作成手順
https://github.com/GomaGoma676/nextjs-testing/blob/main/README.md
この通りにやる

なお自分のプロジェクト作成場所はユーザーフォルダ直下にreact-testフォルダを作ってその下にfrontendフォルダを作成しそこを起点とした

1-1. create-next-app

npx create-next-app . --use-npm

成功するとこんな感じの文字が出る

Success! Created frontend at C:\Users\XXXXXXX\projects\udemy-react-test\frontend
Inside that directory, you can run several commands:

npm run dev
Starts the development server.

npm run build
Builds the app for production.

npm start
Runs the built app in production mode.

We suggest that you begin by typing:

cd C:\Users\XXXXXXX\projects\udemy-react-test\frontend
npm run dev


1-2. 必要 module のインストール

npm install axios msw swr

成功するとこのような感じになる
個人的にエラーゼロでうれしい(create-react-appで作成したプロジェクトはなぜかWARNINGだらけでvulnerabilitiesが大量に発生する)

  1. axios@0.24.0
  2. swr@1.0.1
  3. msw@0.35.0

added 92 packages from 86 contributors and audited 534 packages in 28.619s

96 packages are looking for funding
run `npm fund` for details

found 0 vulnerabilities

1-2-ex. VSCode拡張機能のインストール
ES7とJESTをインストール(Prettierも)
f:id:shinseidaiki:20211108000313p:plain
f:id:shinseidaiki:20211108000255p:plain
f:id:shinseidaiki:20211108000334p:plain

prettierの設定は設定>save>Editor:Format On Saveにチェックを入れる
f:id:shinseidaiki:20211108000413p:plain
f:id:shinseidaiki:20211108000542p:plain

1-3. prettierの設定 : package.json
不要なセミコロンを消してくれる設定

    "prettier": {
        "singleQuote": true,
        "semi": false
    }


2. React-testing-library の導入

2-1. 必要 module のインストール

Testのためのモジュール群をインストール
jest-css-modulesはテスト時に不具合を出す可能性のあるCSSのモック化のためのライブラリ

npm install -D jest @testing-library/react @types/jest @testing-library/jest-dom @testing-library/dom babel-jest @testing-library/user-event jest-css-modules

2-2. Project folder 直下に".babelrc"ファイルを作成して下記設定を追加

コマンドじゃなくて普通に作成してもよい(windowsユーザーはtouch使えないので右クリックで普通に作る)

touch .babelrc

作ったファイルに以下記載(next.jsのプロジェクトに対してテスト実行するということを伝えるという記述)

    {
        "presets": ["next/babel"]
    }

2-3. package.json に jest の設定を追記

    "jest": {
        "testPathIgnorePatterns": [
            "<rootDir>/.next/",
            "<rootDir>/node_modules/"
        ],
        "moduleNameMapper": {
            "\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
        }
    }

testPathIgnorePatternsはテストとは関係ないフォルダを無視する記述
"moduleNameMapper"はCSSファイルがプロジェクトに存在した場合はmock化する記述


2-4. package.jsonに test scriptを追記
test行だけを追記

    "scripts": {
        ...
        "test": "jest --env=jsdom --verbose"
    },

この記述があればターミナルからnpm testを使ってテストを実行することができるようになる

    • env=jsdom --verboseのオプションはデフォルトでは全テストケースの成功有無しか出力されないところを、各テストケースの成功有無を出力させてもらえるようになる記述

3. TypeScript の導入

この手順と同じ
https://nextjs.org/learn/excel/typescript/create-tsconfig

3-1. 空のtsconfig.json作成
touch tsconfig.jsonとか書いてるけど右クリックで作成でよい

3-2. 必要moduleのインストール

TypeScriptのインストール

npm install -D typescript @types/react @types/node

3-3. 開発server起動

npm run dev

これを実行するとtsconfig.jsonの内容が自動的に生成される

正常に実行されたのち、Ctrl + Cでプロセスを切断


3-4. _app.js, index.js -> tsx へ拡張子変更
f:id:shinseidaiki:20211108003113p:plain

apiフォルダは使用しないため削除する


3-5. AppProps型追記

_app.tsx内に記述を追加
import { AppProps } from 'next/app'と: AppPropsが追記する要素

    import { AppProps } from 'next/app'

    function MyApp({ Component, pageProps }: AppProps) {
        return <Component {...pageProps} />
    }

    export default MyApp

4. Tailwind CSS の導入
https://tailwindcss.com/docs/guides/nextjs

4-1. 必要moduleのインストール

テイルウィンドウのインストール

npm install tailwindcss@latest postcss@latest autoprefixer@latest


4-2. tailwind.config.js, postcss.config.jsの生成

コマンド実行で上記二つのファイルを自動生成

npx tailwindcss init -p


4-3. tailwind.config.jsのpurge設定追加

パージ内に'./pages/**/*.tsx', './components/**/*.tsx'を追記
これはpagesやcomponentsフォルダ下の実際に使用されているtsxファイルのユーティリティファイルに対してデプロイするときに自動生成されるCSSファイルについて、記載したフォルダのものだけをCSSに自動生成する記述
これを設定しておかないと存在するtailwindのクラスユーティリティすべてがCSSに出力されてファイルサイズが膨大になる

module.exports = {
    purge: ['./pages/**/*.tsx', './components/**/*.tsx'],
    darkMode: false,
    theme: {
        extend: {},
    },
    variants: {
        extend: {},
    },
    plugins: [],
}

4-3-x. componentsフォルダをプロジェクト直下に作成する
普通に作成する


4-4. globals.cssの編集

stylesフォルダにあるblobals.cssのすでに記載されている内容を以下の三行で置き換える

@tailwind base;
@tailwind components;
@tailwind utilities;

5. 動作確認

5-1. index.tsxの編集

既に存在するコードを以下の内容に置き換える

const Home: React.FC = () => {
  return (
    <div className="flex justify-center items-center flex-col min-h-screen font-mono">
      Hello Nextjs
    </div>
  )
}
export default Home

また既存のデフォルトコードで使用されていたHome.module.cssは使用しないため、削除する

その後開発サーバーを実行してTailwind CSSが効いているかブラウザで確認する

npm run dev 

Hello Nextjsの文字列が中央寄せになっていることが確認できる
f:id:shinseidaiki:20211108005102p:plain


5-2. __tests__フォルダとHome.test.tsxファイルの作成

まずはテストファイルを格納するための__tests__フォルダをプロジェクト直下に作成して、その後、直下にHome.test.tsxを作成する
f:id:shinseidaiki:20211108005427p:plain

Home.test.tsxに以下の内容を記述
itの中身がテストケース
コンポーネントの画面が正しく'Hello Nextjs'という内容をレンダリングしているかというのを確認するテスト
render()でHTMLの構造を取得でき、その中からお目当ての要素の確認をすることができる

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Home from '../pages/index'

it('Should render hello text', () => {
  render(<Home />)
  expect(screen.getByText('Hello Nextjs')).toBeInTheDocument()
})


npm test -> テストがPASSするか確認

テストの実行は以下のコマンド

npm test


成功した場合はこのような文字が出力される

PASS __tests__/Home.test.tsx
√ Should render hello text (36 ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.429 s
Ran all test suites.

失敗の場合は原因などの情報を含めて出力される

FAIL __tests__/Home.test.tsx
× Should render hello text (40 ms)

● Should render hello text

TestingLibraryElementError: Unable to find an element with the text: Hello Nextjs2. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

......

Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 3.296 s
Ran all test suites.
npm ERR! Test failed. See above for more details.


テストのデバッグをしたい場合はscreen.debug()をテストコードの中に記載して確認することもできる

it('Should render hello text', () => {
  render(<Home />)
  screen.debug()
  expect(screen.getByText('Hello Nextjs2')).toBeInTheDocument()
})

デバックを入れているときのテストが成功する場合のコンソールへの出力例
(テスト失敗の時はデフォルトでデバッグ出力されている模様)
レンダリングされているHTMLの要素を出力させている

console.log



Hello Nextjs

※Nextjs ver12.0 + Next-page-tester互換性

現状(2021/11/3)、next-page-testerがNextjs ver12に対応していない為、Nextのversionを11系に変更する必要がある模様
npm install

yarn add next@11.1.2


※ReferenceError: setImmediate is not defined 対処法

Navigationのテスト実行時、"setImmediate is not defined"というエラーが発生した場合

1. setimmdiate パッケージのnpmインストール

npm i setimmediate

2 . 各テストファイルのimport部に下記importを追加(※以降すべてのテストファイルに追記)

import "setimmediate"


※Nextjs ver11.0対応

Nextjs ver11.0にした場合はLayout componentの修正

1. Layout componentに Imageのimport文を追加

import Image from 'next/image';

2. をNextの表記に変更

{/*  <img ............................. />  */}
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />

Static Site Generation 実装パターンのテスト作成

静的なコンテンツをテストするパターン

今回は5つの画面を遷移するテストを実装する

components/Layout.tsxを作成
Layoutはすべての画面で共通して使われるベースの画面
基本形

interface TITLE {
    title: string
}

const Layout: React.FC<TITLE> = ({ children, title = 'Next.js' }) => {
    return (
        <div>
            
        </div>
    )
}

export default Layout


returnの中身
タグがルーティング

import Head from 'next/head';
import Link from 'next/link';


return (
        <div className="flex justify-center intems-center flex-col min-h-screen font-mono">
            <Head>
                <title>{title}</title>
            </Head>
            <header>
                <nav className="bg-gray-800 w-screen">
                    <div className="flex items-center pl-8 h-14">
                        <div className="flex space-x-4">
                            <Link href="/">
                                <a data-testid="home-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Home</a>
                            </Link>
                            <Link href="/blog-page">
                                <a data-testid="blog-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Blog</a>
                            </Link>
                            <Link href="/comment-page">
                                <a data-testid="comment-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Comment</a>
                            </Link>
                            <Link href="/context-page">
                                <a data-testid="context-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Context</a>
                            </Link>
                            <Link href="/task-page">
                                <a data-testid="task-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Todos</a>
                            </Link>
                        </div>
                    </div>
                </nav>
            </header>
            <main className="flex flex-1 justify-center item-center flex-col w-screen">
                {children}
            </main>
            <footer className="w-full h-12 flex justify-center items-center border-t">
                <a className="flex items-center"
                    href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
                    target="_blank"
                    rel="noopener noreferrer"
                >Powered by <img src="/vercel.svg" alt="Vercel Logo" width={72} height={16} /></a>
            </footer>
        </div>
    )

次にindex.tsxでホーム画面を作成する
Layout.tsxをベースに作成するので、return 直下をLayoutで囲う

import Layout from "../components/Layout"

return (
    <Layout title="Home">
      <div>...</div>
    </Layout>
  )


次にblog-page,tsxを作成する
画面となるので、pagesフォルダ下に作成する
index.tsxのコードを流用
ファンクションコンポーネント名とエクスポート名をBlogPageに置き換え、他も必要な個所を修正する

他のcomment-page.tsxなども同様に作成していく



すべて作成終わったのちに、テストケースの修正をする
index.tsxの内容が修正されているのでHome.test.tsxのテストする内容を修正する


次にテスターがナブバーをクリックしてページ遷移するかどうかを確認するためのテストを行う
NavBar.test.tsxを__tests__直下に作成

Linkコンポーネントのテストをする場合は、以下のパッケージをインストールする必要がある

npm install next-page-tester

※これでエラーが出る場合は以下を実行してから再度上記を実行

npm install next@11.1.2
[※yarnの場合]  yarn add next@11.1.2 

nextをバージョン11に戻した場合
Layout.tsxでimgで記載している箇所をImageに修正

import Image from 'next/image';


  Powered by{' '} 
  <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />


基本的なテストモジュールのインポート

import {render, screen} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

NavBarテストのために必要なモジュールのインストールとおまじない

import useEvent from '@testing-library/user-event'; // NavBarをクリックさせるために必要
import { getPage } from 'next-page-tester';
import { initTestHelpers } from 'next-page-tester'; // 初期化を行う

initTestHelpers()


テストケースを書く
テストの基本形はこちら

describe('Navigation by Link', () => {
    it('Should route to selected page in navbar', async () => {
        
    })
})


取得したいページを読み込み、テストできる形にレンダーできる処理

const { page } = await getPage({
    route: '/index',
})
render(page)

NavBarのブログをクリックするためにはLayout.tsxのdata-testidを手掛かりにする

Blogをクリックする

useEvent.click(screen.getByTestId('blog-nav'))

意図した挙動となっているか確認する

expect(await screen.findByText('blog page')).toBeInTheDocument()


テストケースの全体像基本形

it('Should route to selected page in navbar', async () => {
        const { page } = await getPage({
            route: '/index',
        })
        render(page)

        useEvent.click(screen.getByTestId('blog-nav'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })

Static Site Generation + PreFecth 実装パータンのテスト作成

Nextjsのスタティックサイトジェネレータを使用する実装
Blogを実装してテストする

JsonPlaceHolderの/postsエンドポイントを使用する
JSONPlaceholder - Free Fake REST API

postsのデータ型を保持するためのデータ型を定義するファイルTypes.tsをプロジェクト直下に作ったtypesに格納する

JsonPlaceHolderでは以下のようなデータ型になっているので、

{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
},

このようなデータ型をTypes.tsに定義する

export interface POST {
    userId: number
    id: number
    title: string
    body: string
}


/commentsエンドポイントのデータ型も使うので、Types.tsに定義する
他も同様

export interface POST {
    userId: number
    id: number
    title: string
    body: string
}
export interface COMMENT {
    postId: number
    id: number
    name: string
    email: string
    body: string
}
export interface TASK {
    taskId: number
    id: number
    title: string
    completed: boolean
}

続いて、libフォルダをプロジェクト直下に作成して、fetch.tsのファイルを作成する

今回はStatic Site Generation + Pre-fetchのデータを取得するパターンの模様
ビルド時に外部のjsonフォルダにアクセスしてデータを取得(Pre-fetch)して、そのデータを使って事前に静的なHTMLを作っておくアクセス方式

サーバーサイドで実行されるPre-fetchの処理を記述していく

不要だが、サーバーサイドでfcthをしていることを明示するために記述しているインポート { fetch } としないように注意

import fetch from "node-fetch";

axiosと同じように実装する
もともとは100件だが ?_limit=10 をつけると10件分取得になる

export const getAllPostData = async () => {
    const res = await fetch(
        new URL('https://jsonplaceholder.typicode.com/posts/?_limit=10')
    )
    const posts = await res.json()
    return posts
}

Postの詳細データ取得

export const getPostData = async (id: string) => {
    const res = await fetch(
        new URL(`https://jsonplaceholder.typicode.com/posts/${id}`)
    )
    const post = await res.json()
    return post
}


コンポーネントにPost.tsx作成して詳細ページ作成
Post.tsx 作る

import Link from 'next/link';
import { POST } from '../types/Types'

const Post: React.FC<POST> = ({ id, title }) => {
    return (
        <div>
            <span>{id}</span>
            {' : '}
            <Link href={`/posts/${id}`}>
                <a className="curxor-pointer border-b border-gray-500 hover:bg-gray-300">
                    {title}
                </a>
            </Link>
        </div>
    )
}

export default Post


[id].tsx 作る

import Link from 'next/link';
import Layout from '../../components/Layout'
import { getAllPostIds, getPostData } from '../../lib/fetch';
import { POST } from '../../types/Types'
import { GetStaticProps, GetStaticPaths } from 'next';

const PostDetail: React.FC<POST> = ({ id, title, body }) => {
    return (
        <Layout title="">
            <p className="m-4">
                {'ID : '}
            </p>
            <p className="mb-4 text-xl font-bold">{title}</p>
            <p className="mx-10 mb-12">{body}</p>
            <Link href="/blog-page">
                <div className="flex cursor-pointer mt-12">
                    <svg 
                        xmlns="http://www.w3.org/2000/svg" 
                        className="h-6 w-6 mr-3" 
                        fill="none" 
                        viewBox="0 0 24 24" 
                        stroke="currentColor"
                    >
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
                    </svg>
                    <a data-testid="back-blog">Back to blog-page</a>
                </div>
            </Link>
        </Layout>
    )
}

export default PostDetail

export const getStaticPaths: GetStaticPaths = async () => {
    const paths = await getAllPostIds()
    return {
        paths,
        fallback: false,
    }
}

export const getStaticProps: GetStaticProps = async (ctx) => {
    const { post: post } = await getPostData(ctx.params.id as string)
    return {
        props: {
            ...post, // id, title, body に分割してファンクショナルコンポーネントのpropsに渡す
        },
    }
}


post-page.tsx 書き換える

import Layout from "../components/Layout"
import { getAllPostData } from "../lib/fetch"
import Post from "../components/Post"
import { GetStaticProps } from "next"
import { POST } from "../types/Types"

interface STATICPROPS {
  posts: POST[]
}

const BlogPage: React.FC<STATICPROPS> = ({ posts }) => {
  return (
    <Layout title="Blog">
      <p className="text-4xl">blog page</p>
      <ul>{posts && posts.map((post) => <Post key={post.id} {...post} />)}</ul>
    </Layout>
  )
}
export default BlogPage

export const getStaticProps: GetStaticProps = async () => {
  const posts = await getAllPostData()
  return {
    props: { posts },
  }
}

Blog-page.tsxに対するテスト実装

以下のコマンドを叩いて、下準備が完了

npm build


BlogPage.test.tsxを作成

テストの基本形 REST APIを使用するケース
サーバーサイドをMock化している

import '@testing-library/jest-dom/extend-expect'
import { render, screen , cleanup } from '@testing-library/react'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { rest } from 'msw'
import { setupServer } from 'msw/node'

initTestHelpers()

const handlers = [
    rest.get(`https://jsonplaceholder.typicode.com/posts/?_limit=10`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        userId: 1,
                        id: 1,
                        title: 'dummy title 1',
                        body: 'dummy body 1',
                    },
                    {
                        userId: 2,
                        id: 2,
                        title: 'dummy title 2',
                        body: 'dummy body 2',
                    },
                ])
            )
        }
    )
]

const server = setupServer(...handlers)
beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

describe('Blog page', () => {
    it('Should render the list of blogs pre-fetched by getStaticProps', async () => {

    })
})

afterEachの中身の処理は各テストケースの処理が終了するごとにハンドラーの中身をお掃除している
afterAllの中身の処理はすべてのテストケースが終了した場合にサーバーを閉じている

テストケースの中身を書く

it('テストの中身', () => {})

const { page } = await getPage({
   route: '/blog-page',
})
render(page)
expect(await screen.findByText('blog page')).toBeInTheDocument()
expect(screen.getByText('dummy title 1')).toBeInTheDocument()
expect(screen.getByText('dummy title 2')).toBeInTheDocument()
BlogDetailのテストも行う

こちらのテストではユーザーイベントを再現するためiuserEventモジュールのインポートを行っている
こちらはpost詳細データ取得の部分もモック化する以外は一覧画面と同じ前処理
BlogDetail.test.tsx のテストの中身

import userEvent from '@testing-library/user-event'

// ..............

describe('Blog detail page', () => {
    it('Should render detailed content of ID 1', async () => {
        const { page } = await getPage({
            route: '/posts/1',
        })
        render(page)
        expect(await screen.findByText('dummy title 1')).toBeInTheDocument()
        expect(screen.getByText('dummy body 1')).toBeInTheDocument()
        // screen.debug()
    })
    it('Should render detailed content of ID 2', async () => {
        const { page } = await getPage({
            route: '/posts/2',
        })
        render(page)
        expect(await screen.findByText('dummy title 2')).toBeInTheDocument()
        expect(screen.getByText('dummy body 2')).toBeInTheDocument()
        // screen.debug()
    })
    it('Should route back to blog-page from detailed page', async () => {
        const { page } = await getPage({
            route: '/posts/2',
        })
        render(page)
        expect(await screen.findByText('dummy title 2')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('back-blog'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
})

userEvent.click(screen.getByTestId('back-blog'))がユーザーイベントを行っている
ユーザーイベントを行うときはtestidを設定しておく必要がある


Propsのテスト

コンポーネントが与えられたPropsに対して正しくレンダリングできているかどうか確かめるテストを作成する
複雑なコンポーネントになればなるほどテストの重要度が増す

Postコンポーネントのpropsに対するテストProps.test.tsxを作成
Propsをテストするテストの基本形はこちらになる

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Post from '../components/Post'
import { POST } from '../types/Types'

describe('Post component with given props', () => {
    let dummyProps: POST
    beforeEach(() => {
        dummyProps = {
            userId: 1,
            id: 1,
            title: 'dummy title 1',
            body: 'dummy body 1',
        }
    })
    it('Should render correctly with given props value', () => {
        render(<Post {...dummyProps} />)
    })
})


Propsのテストケースの実装

it('Should render correctly with given props value', () => {
    render(<Post {...dummyProps} />)
    expect(screen.getByText(dummyProps.id)).toBeInTheDocument()
    expect(screen.getByText(dummyProps.title)).toBeInTheDocument()
})

スクリーンデバッグを入れて確認するとダミーで渡したPropsの値が表示されている

console.log    
    <body>       
      <div>      
        <div>    
          <span> 
            1    
          </span>
           :
          <a
            class="curxor-pointer border-b border-gray-500 hover:bg-gray-300"
            href="/posts/1"
          >
            dummy title 1
          </a>
        </div>
      </div>
    </body>

Static Side Generation + Client Side Fetchingの実装ケースにおけるテストの作成

build時にDBの中身はPre-Fetchをしないパターンの実装(静的な内容に関してはプリレンダリングされる)
SEO対策が必要のないコンテンツに有効
サイトの例としてはTodoリストやDashboardなど

今回はコメント機能を作成してテスト実装する

components/Comment.tsx

import { COMMENT } from '../types/Types'

const Comment: React.FC<COMMENT> = ({ id, name, body }) => {
    return (
        <li className="mx-10">
            <p>
                {id}
                {' : '}
                {body}
            </p>
            <p className="text-center mt-3 mb-10">
                {'by '}
                {name}
            </p>
        </li>
    )
}

export default Comment

pages/comment-page.tsx

import Layout from '../components/Layout'
import useSWR from 'swr'
import axios from 'axios'
import Comment from '../components/Comment'
import { COMMENT } from '../types/Types'

const axiosFetcher = async () => {
    const result = await axios.get<COMMENT[]>(
        `https://jsonplaceholder.typicode.com/comments/?_limit=10`
    )
    return result.data
}

const CommentPage: React.FC = () => {
  const { data: comments, error } = useSWR('commentsFetch', axiosFetcher)
    
  if (error) return <span>Error!</span>

  return (
    <Layout title="Comment">
      <p className="text-4xl m-10">comment page</p>
      <ul>{comments && comments.map((comment) => <Comment key={comment.id} {...comment} />)}</ul>
    </Layout>
  )
}

export default CommentPage
テスト実装の前に

useSWRの仕様を確認しておく
https://swr.vercel.app/ja, https://swr.vercel.app/ja/docs/options
initialDataは初期値であり、feedbackに名前が変更になっている
revalidateOnMount: trueにしているとリアクトコンポーネントやページがマウントされたときにサーバーから最新のデータを取得しに行く(デフォルトでtrueだが、initialDataを設定している場合に明示的にtrueにしなければならない制約がある)
refreshInterval: defaultでは0秒だが、1000msに設定すると1sごとに定期的にデータを更新することができる
リアルタイムダッシュボードなどに利用できる
ただしサーバーへの負荷はかなり高くなる
dedupingInterval: デフォルトでは2秒間に複数のリクエストがあった場合は最初の一回だけを実行してサーバーへの負荷を低減させる
キャッシュを活用したい場合はこの時間を長く設定するとよいとされる
また、テストで使用する場合はdedupingInterval は 0 とすることが推奨されている

CommentPage.test.tsxテストの実装

SWRのテストの場合は他のテストの必要なコンポーネントに加えて以下のコンポーネントを追加する

import { SWRConfig, Cache } from 'swr'

ダミーとなるサーバーを作成する(サーバーのモック化)

const server = setupServer(
    rest.get(`https://jsonplaceholder.typicode.com/comments/?_limit=10`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        userId: 1,
                        id: 1,
                        name: 'A',
                        email: 'dummya@gmail.com',
                        body: 'dummy body a',
                    },
                    {
                        userId: 2,
                        id: 2,
                        name: 'B',
                        email: 'dummyb@gmail.com',
                        body: 'dummy body b',
                    },
                ])
            )
        }
    )
)

ダミーサーバーの前処理・後処理の実装

beforeAll(() => server.listen())
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => server.close())

テストケースの作成
正常系

describe('Comment page with useSWR / Success+Error', () => {
    it('Should render the value fetched by useSWR', async () => {
        render(
            <SWRConfig value={{ dedupingInterval: 0 }}>
                <CommentPage />
            </SWRConfig>
        )
        expect(await screen.findByText('1: test body a')).toBeInTheDocument()
        expect(screen.getByText('2: test body b')).toBeInTheDocument()
    })
})

異常系
server.use( rest.get( のステータスを400にすることでErrorを意図的に起こすことができる

it('Should render Error text when fetch failed', async () => {
        server.use(
            rest.get(
                `https://jsonplaceholder.typicode.com/comments/?_limit=10`, (req, res, ctx) => {
                    return res(
                        ctx.status(400),
                    )
                }
            )
        )
        render(
            <SWRConfig value={{ dedupingInterval: 0 }}>
                <CommentPage />
            </SWRConfig>
        )
        expect(await screen.findByText('Error!')).toBeInTheDocument()
    })

screen.debug()でエラーが出ていることを確認できる

console.log   
    <body>      
      <div>     
        <span>  
          Error!
        </span> 
      </div>    
    </body>

複数コンテキストをグローバルステートで管理しているコンポーネントに対するテストの実装

複数のコンポーネントにまたがってステートを管理している状態管理のテスト
状態管理をするcontextフォルダとStateProvider.tsxを作成、プロバイダーを利用するコンポーネントを2つつかったページを作成してテストを行う

状態管理基本形 StateProvider.tsx

import { useContext, useState, createContext } from "react";

const StateContext = createContext(管理するステートのデータ型)

export const StateProvider: React.FC = ({ children }) => {
    const [管理したいステート, セットステート] = useState(初期値)

    return (
        <StateContext.Provider value={{ 状態管理するステート,  セットステート }}>
            {children}
        </StateContext.Provider>
    )
}

export const useStateContext = () => useContext(StateContext)

export const useStateContext の処理に関して、本来はProviderを使用したい場合はコンポーネント側でuseContextをインポートして引数からStateContextを渡す必要があるところを、呼び出し側でStateContextを毎回呼び出さないで済むように実装している


状態管理するステートは toggle: boolean型とする
CreateContext()を使ってコンテキストを作成する
TypeScriptは管理するステートの型を定義する必要があり、toggleだけでなくsetToggleも型を明示する必要がある
setToggleはカーソルをホバーすると表示される
表示される型の属性にReact.を付与することで定義できる
f:id:shinseidaiki:20211122014029p:plain

StateProvider.tsx 実装完成形

import { useContext, useState, createContext } from "react";

const StateContext = createContext(
    {} as {
        toggle: boolean
        setToggle: React.Dispatch<React.SetStateAction<boolean>>
    }
)

export const StateProvider: React.FC = ({ children }) => {
    const [toggle, setToggle] = useState(false)

    return (
        <StateContext.Provider value={{ toggle, setToggle }}>
            {children}
        </StateContext.Provider>
    )
}

export const useStateContext = () => useContext(StateContext)


StateProvider.tsxを利用するコンポーネントのContextAとContextBの2種類のコンポーネントを作成する

状態管理を利用するコンポーネントの基本形

import { useStateContext } from "../context/StateProvider"

const ContextA: React.FC = () => {
    const { 使用するステート, 対になるセットステート} = useStateContext()

    return (
        <>
        </>
    )
} 

export default ContextA

セットステートは参照するだけなら不要
使用するステートが複数ある場合は列挙して書く


ContextA.tsx return中身

<button
    className="bg-gray-500 hover:bg-gray-400 px-3 py-2 mb-5 text-white rounded focus:outline-none"
    onClick={() => setToggle(!toggle)}
>
    change
</button>
<p>Context A</p>
<p className="mb-5 text-indigo-600" data-testid="toggle-a">
    {toggle ? 'true' : 'false'}
</p>

ContextB.tsx toggleコンテキストを参照するだけ

import { useStateContext } from "../context/StateProvider"

const ContextB: React.FC = () => {
    const { toggle } = useStateContext()
    return (
        <>
            <p>Context A</p>
            <p className="text-indigo-600" data-testid="toggle-b">
                {toggle ? 'true' : 'false'}
            </p>
        </>
    )
} 

export default ContextB

context-pageにコンテキストA, Bを表示させる
Providerを使用する際は使用したい場所の直前で囲むことでProviderを利用することができる
またアプリ全体でProviderを利用する場合はindexや_appからプロバイダーを囲うことで毎回プロバイダーを呼ぶ手間がかからなくすることもできる

<StateProvider>
  <ContextA />
  <ContextB />
</StateProvider>
状態管理のテストの実装

Context.test.tsx作成

状態管理テストの基本形

import '@testing-library/jest-dom/extend-expect'
import { render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { StateProvider } from '../context/StateProvider'  // 状態管理のプロバイダー
import ContextA from '../components/ContextA' // 複数のコンポーネント
import ContextB from '../components/ContextB'  // 複数のコンポーネント

describe('Global state management (useContext)', () => {
    it('should change the toggle state globally', () => {
        render(
            <StateProvider>
                <ContextA />
                <ContextB />
            </StateProvider>
        )
    })
})

Context.test.tsx テストケース作成

expect(screen.getByTestId('toggle-a').textContent).toBe('false')
expect(screen.getByTestId('toggle-b').textContent).toBe('false')
userEvent.click(screen.getByRole('button'))
expect(screen.getByTestId('toggle-a').textContent).toBe('true')
expect(screen.getByTestId('toggle-b').textContent).toBe('true')

testidで指定したデータの内容を読む場合はテキストの場合、.textContentをつける
ボタンを押すときはgetByRole('button')を使用する

テストが成功ればよい
状態管理されているテストは特段変わったことをする必要はない

Static Site Generation + Pre-Fetch + Client side fetching が実装されたアプリのテスト実装方法

動的データがビルド時に事前にプリレンダリングされていてかつ、最新データもリアルタイムで反映されてかつ、SEO対策も施されているパターンの実装におけるテストを実装していく

トラブルシュート

useSWRのinitialDataが廃止 → fallbackDataに変更

task-page.tsxで Static Site Generation + Pre-Fetch + Client side fetching の実装を行っていく

task-page.tsx

import Layout from "../components/Layout"
import { GetStaticProps } from "next"
import { getAllTaskData } from "../lib/fetch"
import useSWR from "swr"
import axios from "axios"
import { TASK } from "../types/Types"

interface STATICPROPS {
  staticTasks: TASK[]
}

const axiosFetcher = async () => {
  const result = await axios.get<TASK[]>(
    `https://jsonplaceholder.typicode.com/todos/?_limit=10`
  )
  return result.data
}

const TaskPage: React.FC<STATICPROPS> = ({staticTasks}) => {
  const { data: tasks, error } = useSWR('todosFetch', axiosFetcher, {
    fallbackData: staticTasks,
    revalidateOnMount: true,
  })

  if (error) return <span>Error!</span>

  return (
    <Layout title="Todos">
      <p className="text-4xl">todos page</p>
      <ul>
        {tasks && tasks.map((task) => (
          <li key={task.id}>
            {task.id}
            {': '}
            <span>{task.title}</span>
          </li>
        ))}
      </ul>
    </Layout>
  )
}
export default TaskPage

export const getStaticProps: GetStaticProps = async () => {
  const staticTasks = await getAllTaskData()
  return {
    props: { staticTasks },
  }
}

最初の挙動としてはビルド時にサーバーサイドで getStaticProps が実行されて、ファンクショナルコンポーネントに渡ったstaticTasks がuseSWRの初期値 fallback に設定されて、静的コンテンツが生成される
その後、運用時にユーザーがアクセスするなどしてランタイムでTaskPageコンポーネントがマウントされた場合はクライアントサイドでuseSWRが実行され、最新のデータがサーバーから取得されて、取得したデータでreturn以下の内容を上書きレンダリングして最新情報を反映するという挙動が行われる

Static Site Generation + Pre-Fetch + Client side fetching のテストの実装

このタイプは2段階に分けたテストを実装していく
1. getStaticPropsと 2. useSWR のテストに分ける

1. getStaticProps関係のテストを行う

TaskPageStatic.test.tsx

/**
 * @jest-environment jsdom
 */
import '@testing-library/jest-dom/extend-expect'
import { render, screen, cleanup } from '@testing-library/react'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import 'setimmediate'

initTestHelpers()
const server = setupServer(
    rest.get(
        'https://jsonplaceholder.typicode.com/todos', (req, res, ctx) => {
        const query = req.url.searchParams
        const _limit = query.get('_limit')
        if (_limit === '10') {
            return res(
                ctx.status(200),
                ctx.json([
                {
                    userId: 3,
                    id: 3,
                    title: 'Static task C',
                    completed: true,
                },
                {
                    userId: 4,
                    id: 4,
                    title: 'Static task D',
                    completed: false,
                },
                ])
            )
        }
    })
)
beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

describe(`Todo page / getStaticProps`, () => {
    it('Should render the list of tasks pre-fetched by getStaticProps', async () => {
        const { page } = await getPage({
            route: '/task-page',
        })
        render(page)

        expect(await screen.findByText('todos page')).toBeInTheDocument()
        expect(screen.getByText('Static task C')).toBeInTheDocument()
        expect(screen.getByText('Static task D')).toBeInTheDocument()
    })
})

モックサーバーを立ててgetStaticPropsのメソッドが正しく動作するかを確認する
task-page.tsxにおける useSWR のtasksにはこのテストで定義した初期値が入る挙動となることを確認できればよい

トラブルシュート

task-pageでtasksがレンダリングされない場合はuseSWRの中の fallbackData が fallback とかになっていないか確認

トラブルシュート

ReferenceError: setImmediate is not defined 対処法
"setImmediate is not defined"エラー発生時

1. setimmdiate パッケージのnpmインストール

npm i setimmediate

2. 各テストファイルのimport部に下記importを追加

import "setimmediate"
2. useSWR関係のテストを行う

こちらのテストを行う際は、TaskPageのgetStaticPropsは使用せずに、ファンクショナルコンポーネントのprops(staticTasks)にダミーデータを渡して行う
useSWRのテストなので、CommentPage.test.tsxで作成したときのようなダミーサーバーの用意とテストしたいページをSWRConfigでラップしてレンダーする実装を行い、かつダミーPropsデータをテスト内で作成してテストを行う

<TaskPage staticTasks={staticProps} />

で渡すダミーデータのstaticPropsを用意する

let staticProps: TASK[]
    staticProps = [
        {
            userId: 3,
            id: 3,
            title: 'Static task C',
            completed: true,
        },
        {
            userId: 4,
            id: 4,
            title: 'Static task D',
            completed: true,
        },
    ]

これがTaskPageのprops ( staticTasks ) に疑似的に渡される
こちらのpropsに直接渡すデータ (id: 3, 4) が最初に表示され、その後、最新情報として設定したconst server = setupServer で定義しているデータ (id: 1, 2) がuseSWRによって次に取得されているかを確認する

TaskPageSWR.test.tsx

/**
 * @jest-environment jsdom
 */
import '@testing-library/jest-dom/extend-expect'
import { render, screen, cleanup } from '@testing-library/react'
import { SWRConfig } from 'swr'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import TaskPage from '../pages/task-page'
import { TASK } from '../types/Types'
import 'setimmediate'

const server = setupServer(
    rest.get(
        'https://jsonplaceholder.typicode.com/todos', (req, res, ctx) => {
        const query = req.url.searchParams
        const _limit = query.get('_limit')
        if (_limit === '10') {
            return res(
                ctx.status(200),
                ctx.json([
                {
                    userId: 1,
                    id: 1,
                    title: 'Static task A',
                    completed: false,
                },
                {
                    userId: 2,
                    id: 2,
                    title: 'Static task B',
                    completed: true,
                },
                ])
            )
        }
    })
)
beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

describe(`Todos page / useSWR`, () => {
    let staticProps: TASK[]
    staticProps = [
        {
            userId: 3,
            id: 3,
            title: 'Static task C',
            completed: true,
        },
        {
            userId: 4,
            id: 4,
            title: 'Static task D',
            completed: true,
        },
    ]
    
    it('Should render CSF data after pre-rendered data', async () => {
        render(
            <SWRConfig value={{ dedupingInterval: 0 }}>
                <TaskPage staticTasks={staticProps} />
            </SWRConfig>
        )

        // props (staticTasks) に疑似的に渡すデータを初めに確認
        expect(await screen.findByText('Static task C')).toBeInTheDocument()
        expect(screen.getByText('Static task D')).toBeInTheDocument()
        screen.debug()
        // server から疑似的な最新データを取得できているかをその後確認
        expect(await screen.findByText('Static task A')).toBeInTheDocument()
        expect(screen.getByText('Static task B')).toBeInTheDocument()
        screen.debug()
    })
})

テストを行うと

npm 

screen.debug() propsデータ取得初期時 画面表示


  • 3
    :

    Static task C


  • 4
    :

    Static task D


screen.debug() 最新情報取得時 画面表示


  • 1
    :

    Static task A


  • 2
    :

    Static task B

想定した挙動で表示されていることがわかる


続いて異常系テストケースを実装する

it('Should render Error text when fetch failed', async () => {
        server.use(
            rest.get(
              'https://jsonplaceholder.typicode.com/todos/',
              (req, res, ctx) => {
                const query = req.url.searchParams
                const _limit = query.get('_limit')
                if (_limit === '10') {
                  return res(ctx.status(400))
                }
              }
            )
          )

        render(
            <SWRConfig value={{ dedupingInterval: 0 }}>
                <TaskPage staticTasks={staticProps} />
            </SWRConfig>
        )
        expect(await screen.findByText('Error!')).toBeInTheDocument()
    })

デプロイ時に自動テスト実行設定をしてVercelにデプロイ

GitHubにプッシュ
Vercelはデフォルトのままデプロイする
deploy時にgetStaticPropsやgetStaticPathsが実行されて静的コンテンツが作成されている

デプロイ時自動テスト設定

VercelのSetting>General>BUILD COMMAND
OverrideをONにして、以下を追記してSave

npm test && npm run build

この設定をするとテストが通らないとデプロイされない

動作確認

テストを失敗するようにコードを書き換えてGitHubに再プッシュすると自動でbuildが開始する
VerceダッシュボードのDeploymentを確認すると

STATUS Error
Build Failed

表示されていることがわかる

コンソールにエラーの理由が記されている

✕ Should render Error text when fetch failed (1028 ms)
Error: Command "npm test && npm run build" exited with 1

ビルドもデプロイもされていないことがわかる

正しく戻して再度デプロイして完了
なお、異常系テストはVercel上でエラーとしてコンソールに表示(400ステータスなど)されるが、テスト自体が通っていれば大丈夫と判定される

END

テスト基礎2 Next js テスト実践編 Django連携編

この章からはこちらを参照
GitHub - GomaGoma676/api-blog-prj: [テスト編] Nextjs + React-testing-libraryでモダンReactソフトウェアテスト / Section 3 : REST API 🚀

トラブルシュート

[MSW] Found a redundant usage of query parameters in the request handler
npm test 実行時に発生する下記のwarning対応策
クエリパラメータ ?_limit=10 をeq経由で取得する形に変える

PyJWT versionでエラーが出るときは以下のバージョンにすること

pip install PyJWT==2.0.0

Django バックエンド作成

Blog機能などを使う
プロジェクト作成方法は前の記事参照

Tips!

seializers.pyにおいて、BlogSerializerで取得するtagsはデフォルトではidとなるが、以下のように上書きをすることでnamフィールドも取得できるようになる
TagsはManyToManyFieldで作っているのでmanyをTrueにする

tags = TagSerializer(many=True, read_only=True)

またリレーション先のusernameを取得したい場合はsource='user.username'で取得できる(Blogモデルでuserと定義しているため)

username = serializers.ReadOnlyField(source='user.username', read_only=True)

Nextjsフロントエンド作成

作成方法は基本的には前のブログにまとめてあるものを参照のこと
https://github.com/GomaGoma676/nextjs-testing-blog


package.json


今回はcookieを使用する

npm install universal-cookie

テスターを使用するのでテスターをインストールする

npm install next-page-tester


index.tsxのページ名はBlogPageにする
Cookiesを利用している部分だけの実装例を以下に記す

import Cookie from "universal-cookie"

const cookie = new Cookie()

const BlogPage: React.FC<STATICPROPS> = ({ posts }) => {
  const [hasToken, setHasToken] = useState(false) // cookieの保持状況はステートとして準備する
  const logout = () => {
    cookie.remove('access_token')
    setHasToken(false)
  }
  const deletePost = async (id: number) => {
    await fetch(`${process.env.NEXT_PUBLIC_RESTAPI_URL}/delete-blog/${id}`, {
      method: 'DELETE',
      headers: {
        Authorization: `JWT ${cookie.get('access_token')}`, // 認証ユーザーのみ削除できる
      },
    }).then((res) => {
      if (res.status === 401) {
        alert('JWT Token not valid')
      }
    })
  }
  useEffect(() => {
    if (cookie.get('access_token')) {
      setHasToken(true)
    }
  }, [])

  return (
    <Layout title="Blog">
      {hasToken && (
        // ログインユーザーのみに表示させたいもの
      )}
    </Layout>
    
  )
}
export default BlogPage
Tips!

CSS classNameのswith文の使用法例

{tags && tags.map((tag, i) => {
                    let bgcolor = 'bg-gray-400'
                    switch (i) {
                        case 0:
                            bgcolor = 'bg-blue-500'
                            break
                        case 1:
                            bgcolor = 'bg-gray-500'
                            break
                        case 2:
                            bgcolor = 'bg-green-500'
                            break
                        case 3:
                            bgcolor = 'bg-yellow-500'
                            break
                        case 4:
                            bgcolor = 'bg-indigo-500'
                            break
                        default:
                            bgcolor = 'bg-gray-400'
                    }
                    return <span className={`px-2 py-2 m-1 text-white rounded ${bgcolor}`}>{tag.name}</span>
                })}

なお、講義では以下のように記述されていた
コードの可読性の観点からすると三項演算子を複数回にわたって多用しない方がよいと考えられる

{tags &&
          tags.map((tag, i) => (
            <span
              className={`px-2 py-2 m-1 text-white rounded ${
                i === 0
                  ? 'bg-blue-500'
                  : i === 1
                  ? 'bg-gray-500'
                  : i === 2
                  ? 'bg-green-500'
                  : i === 3
                  ? 'bg-yellow-500'
                  : i === 4
                  ? 'bg-indigo-500'
                  : 'bg-gray-400'
              }`}
              key={tag.id}
            >
              {tag.name}
            </span>
          ))}
Tips! ページリンクの呼び出し方

リンクは通常以下のような形であるが、関数の中で呼び出したい場合はuseRouterを使用することができる

<Link href="/${呼び出し先}">
 <a>リンク名称</a>

useRouter()を使用する場合

import { useRouter } from "next/router"

// .........................

  const router = useRouter()

  const login = async () => {
        try {
            const res = await axios.post.............................................
            if (res.status === 200) {
                // ...........
                router.push('/')
            }
        }
    } 

Loginコンポーネントのテストを実装 !!

ログインコンポーネントテスト基本形
AuthPage.test.tsx

import { render, screen, cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { setupEnv } from "../test/setupTest" 

initTestHelpers()

const handlers = [
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
            return res(ctx.status(200), ctx.json({ access: '123xyz' }))
        }
    ),
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/register/`, (req, res, ctx) => {
            return res(ctx.status(201))
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})


describe('AdminPage Test Cases', () => {
    it('Should route to index-page when login succeeded', async () => {

    })
})

ハンドラーを作成してログインユーザーをモック化

トラブルシュート

環境変数読み込めない問題
以下の対応3で対応する
https://shinseidaiki.hatenablog.com/entry/2021/11/20/221502

ログインテストケース基本形

基本形は以下

import { render, screen, cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { setupEnv } from "../test/setupTest" 

initTestHelpers()
setupEnv()

const handlers = [
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
            return res(ctx.status(200), ctx.json({ access: '123xyz' }))
        }
    ),
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/register/`, (req, res, ctx) => {
            return res(ctx.status(201))
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})


describe('AdminPage Test Cases', () => {
    it('testcase1', async () => {
    })
    it('testcase2', async () => {
    })
})
Next.jsテストAssert基本形

Next.jsにおけるAssertはexpectとなる
基本形は以下

expect(await screen.findByText('Login')).toBeInTheDocument()
Next.jsテスターイベント実行基本形

Next.jsにおけるテスターによるユーザーイベントの動作実行はuserEventを使用する
基本形は以下

userEvent.type(screen.getByPlaceholderText('Username'), 'user1')

typeを使用した場合は、第一引数に入力させたい要素を指定する
よく使うものはgetByTextやgetByTestId
第二引数に入力させたいデータを入れる
userEventにはclickも存在する

テストケース1 (正常系)ログイン成功時のテスト

AdminPage画面にて正しくログインできたのちBlog-pageに遷移する挙動を確認する
change-modeの挙動も確認する
AuthPage.test.tsx

it('Should route to index-page when login succeeded', async () => {
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Login with JWT'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
テストケース2 (異常系)ログイン失敗のテスト

ログイン失敗の際は画面遷移せずにエラーを表示させる
異常系のエラーをモック化する場合は、serverの処理をオーバーライドすることで実現可能
AuthPage.test.tsx

it('Should not route to index-page when login failed', async () => {
        server.use(
            rest.post(
                `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
                    return res(ctx.status(400))
                }    
            )
        )
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Login with JWT'))
        expect(await screen.findByText('Login Error')).toBeInTheDocument()
        expect(screen.getByText('Login')).toBeInTheDocument()
        expect(screen.queryByText('blog page')).toBeNull()
    })


serverのオーバーライドは以下のように行う

server.use(
            rest.post(
                `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
                    return res(ctx.status(400))
                }    
            )
        )

存在しないものの判定は以下のように記述

expect(screen.queryByText('blog page')).toBeNull()
テストケース3 ユーザー登録画面を表示することができるかを確認

mode changeをクリックして表示が切り替わるかを確認
AuthPage.test.tsx

it('Should change to register mode', async () => {
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        expect(screen.getByText('Login with JWT')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('mode-change'))
        expect(screen.getByText('Sign up')).toBeInTheDocument()
        expect(screen.getByText('Create new user')).toBeInTheDocument()
    })
テストケース4 (正常系)ユーザー登録が成功してその後ログインも成功して画面が遷移するかを確認

AuthPage.test.tsx

it('Should route to index-page when register+login succeeded', async () => {
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('mode-change'))
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Create new user'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
テストケース5 (異常系)ユーザー登録が失敗して画面が遷移せずエラーが表示されるかを確認
it('Should not route to index-page when register+login failed', async () => {
        server.use(
            rest.post(
                `${process.env.NEXT_PUBLIC_RESTAPI_URL}/register/`, (req, res, ctx) => {
                    return res(ctx.status(400))
                }    
            )
        )
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('mode-change'))
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Create new user'))
        expect(await screen.findByText('Registration Error')).toBeInTheDocument()
        expect(screen.getByText('Sign up')).toBeInTheDocument()
        expect(screen.queryByText('blog page')).toBeNull()
    })

ブログページのテストを行う

クッキーが存在する場合のテストの基本形

各テストケースが終わるたびにクッキーを削除している

document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'

access_token=; アクセストークンを空にして
expires: 有効期限を過去に設定する


他は同様の実装
BlogPage.test.tsx

import { render, screen, cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { setupEnv } from "../test/setupEnv" 

initTestHelpers()
setupEnv()

const handlers = [
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(async () => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
    document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
})
afterAll(() => {
    server.close()
})


describe('BlogPage Test Cases', () => {
    it('', async () => {
    })
})
テストケース1 画面表示&遷移確認
it('Should route to admin page and route back to blog page', async () => {
        const { page } = await getPage({
            route: '/',
        })
        render(page)
        userEvent.click(screen.getByTestId('admin-nav'))
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('blog-nav'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
テストケース2 クッキーが存在する場合にアイコンが表示される

Cookieがない場合にはアイコンが表示されない
クッキーを存在するように設定するやり方は以下

document.cookie = 'access_token=123xyz;'

アイコンの表示確認はtestidを確認する

expect(screen.getByTestId('logout-icon')).toBeInTheDocument()


BlogPage.test.tsx

it('Should render delete btn + logout btn when JWT token cookie exist', async () => {
        document.cookie = 'access_token=123xyz;'

        const { page } = await getPage({
            route: '/',
        })
        render(page)
        expect(await screen.findByText('blog page')).toBeInTheDocument()
        expect(screen.getByTestId('logout-icon')).toBeInTheDocument()
        expect(screen.getByTestId('btn-1')).toBeInTheDocument()
        expect(screen.getByTestId('btn-2')).toBeInTheDocument()
    })
テストケース3 クッキーが存在しない場合にアイコンが表示されない

Cookieがない場合は特段変わった処理を行う必要はない
確認が.toBeNull()になるだけ

it('Should not render delete btn + logout btn when no cookie', async () => {
        const { page } = await getPage({
            route: '/',
        })
        render(page)
        expect(await screen.findByText('blog page')).toBeInTheDocument()
        expect(screen.queryByTestId('logout-icon')).toBeNull()
        expect(screen.queryByTestId('btn-1')).toBeNull()
        expect(screen.queryByTestId('btn-2')).toBeNull()
    })

ないもの判定はqueryByTestId()を使用することに注意


テストケース4 prefetchでレンダリングされているブログのタイトルが表示されているか確認する
it('Should render the list of blogs pre-fetched by getStaicProps', async () => {
        const { page } = await getPage({
            route: '/',
        })
        render(page)
        expect(await screen.findByText('blog page')).toBeInTheDocument()
        expect(screen.getByText('title1')).toBeInTheDocument()
        expect(screen.getByText('title2')).toBeInTheDocument()
    })

個別ページのテスト

個別ページの基本形

個別のレスポンスのモック化が増える

rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/1`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json({
                    id: 1,
                    title: 'title1',
                    content: 'content1',
                    username: 'username1',
                    tags: [
                        { id: 1, name: 'tag1' },
                        { id: 2, name: 'tag2' },
                    ],
                    created_at: '2021-01-12 14:59:41',
                })
            )
        }
    ),

それ以外は同じ BlogDetail.test.tsx

import { render, screen, cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { setupEnv } from "../test/setupEnv" 

initTestHelpers()
setupEnv()

const handlers = [
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/1`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json({
                    id: 1,
                    title: 'title1',
                    content: 'content1',
                    username: 'username1',
                    tags: [
                        { id: 1, name: 'tag1' },
                        { id: 2, name: 'tag2' },
                    ],
                    created_at: '2021-01-12 14:59:41',
                })
            )
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/2`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json({
                    id: 2,
                    title: 'title2',
                    content: 'content2',
                    username: 'username2',
                    tags: [
                        { id: 1, name: 'tag1' },
                        { id: 2, name: 'tag2' },
                    ],
                    created_at: '2021-01-13 14:59:41',
                })
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(async () => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
    document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
})
afterAll(() => {
    server.close()
})

describe('BlogDetailPage Test Cases', () => {
    it('', async () => {
    })
})
テストケース1-2 個別ID1-2データが取得できているかを確認
it('Should render detailed content of ID 1', async () => {
        const { page } = await getPage({
            route: '/posts/1',
        })
        render(page)
        expect(await screen.findByText('title1')).toBeInTheDocument()
        expect(screen.getByText('content1')).toBeInTheDocument()
        expect(screen.getByText('by username1')).toBeInTheDocument()
        expect(screen.getByText('tag1')).toBeInTheDocument()
        expect(screen.getByText('tag2')).toBeInTheDocument()
    })
    it('Should render detailed content of ID 2', async () => {
        const { page } = await getPage({
            route: '/posts/2',
        })
        render(page)
        expect(await screen.findByText('title2')).toBeInTheDocument()
        expect(screen.getByText('content2')).toBeInTheDocument()
        expect(screen.getByText('by username2')).toBeInTheDocument()
        expect(screen.getByText('tag1')).toBeInTheDocument()
        expect(screen.getByText('tag2')).toBeInTheDocument()
    })
テストケース3 個別ページからブログ一覧に戻れるかを確認
it('Should route back to blog-page from detail page', async () => {
        const { page } = await getPage({
            route: '/posts/',
        })
        render(page)
        expect(await screen.findByText('title2')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('back-blog'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })

詳細ページのPostDetail単体テスト

Props.test.tsxを作成
疑似的なPropsを詳細ページのPropsに渡して想定通りレンダリングされるかを確認する

ダミーProps動作確認テスト基本形
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import PostDetail from '../pages/posts/[id]'
import { POST } from '../types/types'


describe('PostDetailPage Test Cases', () => {
    const dummyProps: POST = {
        id: 1,
        title: 'title1',
        content: 'content1',
        username: 'username1',
        tags: [
            { id: 1, name: 'tag1' },
            { id: 2, name: 'tag2' },
        ],
        created_at: '2021-01-12 14:59:41',
    }
    it('Should render correctly with given props value', async () => {
         render(<PostDetail {...dummyProps} />)
    })
})
テストケース1 dummyPropsが画面上に表示できているかを確認
it('Should render correctly with given props value', async () => {
        render(<PostDetail {...dummyProps} />)
        expect(screen.getByText(dummyProps.title)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.content)).toBeInTheDocument()
        expect(screen.getByText(`by ${dummyProps.username}`)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.tags[0].name)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.tags[1].name)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.created_at)).toBeInTheDocument()
    })

END

Next.jsのStatic Site Generation(HTMLの事前生成)に関する4パターンの整理

Next.js は基本的にデフォルトでビルド時にHTMLを事前生成する
ビルド時にDBからPre-FetchをしてHTMLを事前生成する方式とアプリ運用時のサーバーアクセス時にDBからHTMLを生成する方法が大きく4種類あるので整理する

SSGはStatic Site Generationの略でユーザーに渡す情報は基本的には静的データを渡そうというコンセプトのもとに作られたアプリがユーザーにデータを渡す仕組みである
従来のデータ配信方式ではDBに格納された情報を毎回動的にHTMLにレンダリングして渡していたが、SSGで実装されたアプリでは一度HTMLにレンダリングされた情報は静的コンテンツにしておいて次回のアクセス時には静的コンテンツとして配信される
このHTMLにレンダリングするタイミングの違いによってSSGは以下の4種類のパターンに分類される

ISRはIncremental Static Regenerationの略で最新情報をHTMLにレンダリングしておいて静的コンテンツにしておく技術である


1.SSG

DocumentやHelpなどのように「完全に静的なページ」
SEOは特に意識しない
代表的なものとしては Index ページがこの実装にあたる場合が多いと思われる
あとはDocumentやHelpページ


2.SSG + Prefetch

ビルド時にデータベースから取得してきた情報を事前にHTMLにレンダリングして静的データとしておくパターンです
Googleクローラーに読み込まれるため、SEO対策に有効です
使われるページの例としてはブログページや商品一覧などが考えられています
ただしそのままだとビルド時の情報のみ使用されるため最新情報を反映させ合たい場合は別途対策が必要です
最新情報を取得する場合にはISRが必要になり、基本的にはISRをセットで利用する場合が多いと思われます
ISRを加えた2.1のパターンがNext.jsで一番よく実装されるであろうと考えられます

2-1. SSG + Prefetch + ISR

Next.jsはビルド時の情報が使われてその後情報が更新されません
後々情報を更新したい場合はISR(DBの最新データで静的サイトを作り直す技術)を使って更新します。
ビルド時にデータを事前にレンダリングするだけでなく、ビルド以降に作成・更新された最新情報に関してもHTML事前レンダリングが実行されて(初回アクセスがトリガー)グーグルのクローラーに分析してもらえるようになり、最新の情報にもSEO対策が施されます


3.SSG + Client side fetching

SEOを意識する必要はないが、最新情報を取得したいページに利用されるパターンです
通常の「create-react-app」(従来のウェブアプリの実装)と似たようなレンダリングの仕組みになります
Client side fetching(通常のAxiosなどのAPI経由でデータを取得する方法)を使って、その都度バックエンドから取得してきたデータをレンダリングしてページを表示させます。
最新データはHTMLにレンダリングされないために新規作成もしくは更新された情報はDBから情報を取得して毎回レンダリングされる仕様となります
DBから情報を取得しないとレンダリングされないのでグーグルのクローラーは有益な情報は何もないと判断してしまい最新の情報に関してはSEOされることはありません
代表的なものとしてはDashboardやTodoリストなどのようなページです


4.SSG + Prefetch + Client side fetching(useSWR)

データをリアルタイムで作成・更新する必要があって、かつ、最新情報にもSEO対策を施す必要があるようなページに利用されるパターンです
2のパターンとほぼ同様ですが、Client side fetchingを使用することで、データの更新をリアルタイムに反映することができます
ただしこの状態では画面表示においてビルド時の過去データと最新情報に乖離がある場合に過去データが表示されたのちに最新データに更新されるという挙動が起きてしまうので、以下の最新データから静的コンテンツを作成しておくISRと組み合わせて使う実装が実用的である

4-1. SSG + Prefetch + Client side fetching(useSWR) + ISR

Next.jsはビルド時の情報が使われてその後情報が更新されません。
後々情報を更新したい場合はISR(DBの最新データで静的サイトを作り直す技術)を使って更新します
ビルド時にデータを事前にレンダリングするだけでなく、ビルド以降に作成・更新された最新情報に関してもHTML事前レンダリングが実行されて(初回アクセスがトリガー)グーグルのクローラーに分析してもらえるようになり、最新の情報にもSEO対策が施されます
初回アクセス以降は事前レンダリングされた最新情報のHTMLが配信されるためISRを使用しないと過去データが表示されたのちに最新データが表示される現象も解決する(具体的にはISRのrevaridateを設定する必要がある)

Nextjs Hasura GraphQL 勉強

はじめに

例のごとく勉強のメモ
GitHub - GomaGoma676/nextjs-hasura-basic-lesson: [Hasura基礎編] Nextjs+Hasura+Apollo Clientで学ぶモダンGraphQL Web開発 🚀

Next.js

フロントエンドのアプリが作れます

Hasura

GrapghQLを使用するためのもの
GrapghQLとセットの代物と思えばいいっぽい

GrapghQL

テーブルの中の特定のフィールドだけ取得することができたりする
効率的なデータ取得ができる模様

APOLLO

GraphQLのクライアントというものらしい
ふむ...


Hasura Cloud

https://hasura.io/cloud/
Githubのアカウントで始められる

1. プロジェクト作る

new projectでFree
FreeではUSのみ

2. お掃除

Ev varsのADMIN_SECRETが作成されてしまった場合は削除

3. コンソール起動

Dataタブクリック
Heroku使う

4. テーブル作り

public > create table
こんな感じで作れる
f:id:shinseidaiki:20211117224552p:plain

Inset rowでデータ追加できる
browse rowsで確認できる

5. APIタブ データのCRUD

チェックつけるだけでこんな感じで使える楽
f:id:shinseidaiki:20211117224945p:plain

下のほうmutationを選択するとAddとかも使えるようになる +のボタンで追加できる
f:id:shinseidaiki:20211117225039p:plain
f:id:shinseidaiki:20211117225244p:plain

limitとか使って任意の件数取得などのオプションを付けられる
aggregateで集計できたりする

特定レコード取りたい場合はコレ
f:id:shinseidaiki:20211117225716p:plain
f:id:shinseidaiki:20211117225823p:plain


データ作成とかはこっち mutationで再生
f:id:shinseidaiki:20211117230011p:plain


データ更新はこう mutationで実行
f:id:shinseidaiki:20211117230429p:plain

削除はこうだよ
f:id:shinseidaiki:20211117230509p:plain

6 グループとリレーション

以下のリレーション関係あり
1to1
1toM
→の方向: Object relationshipと呼ぶ
←の方向: Array relationshipと呼ぶ
MtoM
互いにObject relationship

1toM

別テーブル作ってModifyのAdd a new Columnからこんな感じで作る
userテーブルにgroup_idつけているこの例は
f:id:shinseidaiki:20211117231331p:plain

そんでForeign Keyをこんな感じで設定
f:id:shinseidaiki:20211117231821p:plain

そいでハスラが提案するリレーションをRelationshipタブで選択する
f:id:shinseidaiki:20211117231955p:plain

リレーションの逆側に関しても明示的に設定をする
f:id:shinseidaiki:20211117232207p:plain

API クエリで確認

userにgroupが新しくできる
f:id:shinseidaiki:20211117232500p:plain

groupにもuserが新しくできる
f:id:shinseidaiki:20211117232641p:plain
f:id:shinseidaiki:20211117232715p:plain

1to1

1to1の作り方は、1toMを作ってrelationship属性にunique属性をつけることで実現させる

プロフィールテーブル作った後userテーブルでフィールド追加してuniqueとする
f:id:shinseidaiki:20211117233003p:plain
そいでForeignKeyは一緒
f:id:shinseidaiki:20211117233148p:plain

そのあとはrelationshipでaddする
profileでもaddする

MtoM

MtoMは中間テーブルを使って実現する
友達のリレーションを作りたいときに、profileとuserをMtoMにするケースなどが代表例

中間テーブルを作る
f:id:shinseidaiki:20211117234005p:plain

profileへのリレーション
f:id:shinseidaiki:20211117234049p:plain

userへのリレーション
f:id:shinseidaiki:20211117234145p:plain


中間テーブルからのrelationshipのaddをsaveで決定する(Objectrelationship)
その後、MtoMしたいテーブルのrelationshipタブからAddをsaveで決定する(Arrray relationship)


Apollo クライアント + Next.js

事前準備

VSコードの拡張機能
ES7
Jest
Prettier

プロジェクトの作成

今回はyarnで作成するのでこちらの手順に従う
GitHub - GomaGoma676/nextjs-hasura-basic-lesson: [Hasura基礎編] Nextjs+Hasura+Apollo Clientで学ぶモダンGraphQL Web開発 🚀

yarnの実行ができない場合の対処法

https://qiita.com/ponsuke0531/items/4629626a3e84bcd9398f
hoge.psはyarn.psに読み替える
結論としてはこのコマンドを実行する
powershellでyarnを実行させたいときは毎回このコマンドをたたくこと

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
起動確認
yarn dev
1. Nextjs Project 新規作成
1-1. yarn install *インストールしていない場合
npm install --global yarn
yarn --version
1-2. create-next-app
npx create-next-app .

Node.js version 10.13以降が必要

1-3. Apollo Client + heroicons + cross-fetch のインストール
yarn add @apollo/client graphql @apollo/react-hooks cross-fetch @heroicons/react
1-4. React-Testing-Library + MSW + next-page-tester のインストール
yarn add -D msw next-page-tester jest @testing-library/react @types/jest @testing-library/jest-dom @testing-library/dom babel-jest @babel/core @testing-library/user-event jest-css-modules
1-5. Project folder 直下に".babelrc"ファイルを作成して下記設定を追加

touch .babelrc

    {
        "presets": ["next/babel"]
    }
1-6. package.json に jest の設定を追記
    "jest": {
        "testPathIgnorePatterns": [
            "<rootDir>/.next/",
            "<rootDir>/node_modules/"
        ],
        "moduleNameMapper": {
            "\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
        }
    }
1-7. package.jsonに test scriptを追記
    "scripts": {
        ...
        "test": "jest --env=jsdom --verbose"
    },
1-8. prettierの設定 : settingsでRequire Config + Format On Saveにチェック

touch .prettierrc

    {
        "singleQuote": true,
        "semi": false
    }

f:id:shinseidaiki:20211119131531p:plain
f:id:shinseidaiki:20211119131613p:plain

2-1. 空のtsconfig.json作成
2-2. 必要moduleのインストール
yarn add -D typescript @types/react @types/node
2-3. 開発server起動
yarn dev
2-4. _app.js, index.js -> tsx へ拡張子変更

f:id:shinseidaiki:20211119131841p:plain

2-5. AppProps型追記
    import { AppProps } from 'next/app'

    function MyApp({ Component, pageProps }: AppProps) {
        return <Component {...pageProps} />
    }

    export default MyApp
3-1. 必要moduleのインストール
yarn add tailwindcss@latest postcss@latest autoprefixer@latest
3-2. tailwind.config.js, postcss.config.jsの生成
npx tailwindcss init -p
3-3. tailwind.config.jsのpurge設定追加
module.exports = {
    purge: ['./pages/**/*.tsx', './components/**/*.tsx'],
    darkMode: false,
    theme: {
        extend: {},
    },
    variants: {
        extend: {},
    },
    plugins: [],
}
3-3-2. componentsフォルダの作成
3-4. globals.cssの編集

中身を以下の3行に完全に置き換える

@tailwind base;
@tailwind components;
@tailwind utilities;
4. Test動作確認
4-1. __tests__フォルダとHome.test.tsxファイルの作成
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Home from '../pages/index'

it('Should render title text', () => {
  render(<Home />)
  expect(screen.getByText('Next.js!')).toBeInTheDocument()
})
4-2. yarn test -> テストがPASSするか確認
 PASS  __tests__/Home.test.tsx
  ✓ Should render hello text (20 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.728 s, estimated 2 s
5. GraphQL codegen
5-1. install modules + init
yarn add -D @graphql-codegen/cli
yarn graphql-codegen init

yarn graphql-codegen init実行時に選択肢が出てくるので以下のように設定

1 What type of application are you building?
React

2. Where is your schema?: (path or url)
GraphQL server URL: Hasura(Hasura Cloud)で作成したDBのGraphQL Endpointを指定する
このエンドポイントは作成時にHerokuの本番環境にdeployされている
f:id:shinseidaiki:20211119133027p:plain

3. Where are your operations and fragments?: (src/**/*.graphql)
プロジェクトフォルダ内で作成したカスタムクエリを使用することができる
定義場所を指定することでCodegenが自動的に解析してくれる
queriesフォルダをプロジェクト直下に作成して、サブフォルダ以下すべてのクエリを解析してくれるように以下のパスを設定
queries/**/*.ts

4. Pick plugins: (Press to select, to toggle all, to invert selection)
デフォルトのまま選択

5. Where to write the output: (src/generated/graphql.tsx)
自動生成させた型定義ファイルをプロジェクトのどのフォルダに出力させるかを設定
types/generated/graphql.tsx

6. Do you want to generate an introspection file? (Y/n)
作らない

7. How to name the config file? (codegen.yml)
提案されている名前を利用

8. What script in package.json should run the codegen?
スクリプト実行名を決めることができる
gen-types

最終設定

? What type of application are you building? Application built with React
? Where is your schema?: (path or url) https://[YOUR-DB-NAME].hasura.app/v1/graphql
? Where are your operations and fragments?: queries/**/*.ts
? Pick plugins: TypeScript (required by other typescript plugins), TypeScript Operations (operations and fragments), TypeScript React 
? Where to write the output: types/generated/graphql.tsx
? Do you want to generate an introspection file? No
? How to name the config file? codegen.yml
? What script in package.json should run the codegen? gen-types
5.1 続き

以下の指示に対してnpmではなく今回はyarnを実行する

Fetching latest versions of selected plugins...

Config file generated at codegen.yml

$ npm install

To install the plugins.

$ npm run gen-types

To run GraphQL Code Generator.

yarnを実行してプラグインをインストール

yarn

TypeScriptモジュールのインストール

yarn add -D @graphql-codegen/typescript
5-2. add queries in queries/queries.ts file
5-3. generate types automatically
yarn gen-types

END

トラブルシュート

next-page-testerがNextjs ver12に対応していないためversion11に変更

yarn add next@11.1.2
graphQLの実行確認

上記の5-2と5-3の手順
queries.ts 基本形

import { gql } from '@apollo/client';

export const GET_USERS = gql`
    query GetUsers {
       // クエリの中身
        users(order_by: {created_at: desc}) {
            id
            name
            created_at
        }
    }
`

クエリの中身にはHasuraで自動生成させたコードをコピペで貼り付ければよい

もう一つqueryを作成 @clientが追加されているとサーバーではなくクライアント側のキャッシュにクエリを送ることができる

export const GET_USERS_LOCAL = gql`
    query GetUsers {
        users(order_by: {created_at: desc}) @client {
            id
            name
            created_at
        }
    }
`

idだけを取得するquery

export const GET_USERIDS = gql`
    query GetUserIds {
        users(order_by: {created_at: desc}) {
            id
        }
    }
`

特定のidを引数にとって特定のユーザー情報を取得するquery

export const GET_USERBY_ID = gql`
    query GetUserById($id: uuid!) {
        users_by_pk(id: $id) {
            id
            name
            created_at
        }
    }
`

作成更新削除 query
HasuraではMutationを利用してqueryを作成させていくf:id:shinseidaiki:20211119141611p:plain

export const CREATE_USER = gql`
    mutation CreateUser($name: String!) {
        insert_users_one(object: { name: $name }) {
            id
            name
            created_at
        }
    }
`

export const DELETE_USER = gql`
    mutation DeleteUser($id: uuid!) {
        delete_users_by_pk(id: $id) {
            id
            name
            created_at
        }
    }
`

export const UPDATE_USER = gql`
    mutation UpdateUser($id: uuid = "", $name: String = "") {
        update_users_by_pk(pk_columns: {id: $id}, _set: {name: $name}) {
            id
            name
            created_at
        }
    }
`

ユーザー作成
コピーして利用していく
f:id:shinseidaiki:20211119141818p:plain
f:id:shinseidaiki:20211119141831p:plain

ユーザー削除
f:id:shinseidaiki:20211119143029p:plain
f:id:shinseidaiki:20211119143040p:plain

ユーザー更新
f:id:shinseidaiki:20211119143437p:plain
f:id:shinseidaiki:20211119143505p:plain

クエリを自動生成

※クエリ名がかぶっていると(今回ではquery GetUsers)エラーが発生するので、@client側のクエリはコメントアウトして、以下を実行

yarn gen-types

※実行後コメントアウトしていた部分のコメントアウトを外す

コンポーネントレイアウトの作成

TypeScriptを利用したNext.jsにおけるLayout.tsx基本形

import { ReactNode, VFC } from "react";
import Head from "next/head";
import Link from "next/link";

interface Props {
    children: ReactNode
    title: string
}

export const Layout: VFC<Props> = ({
    children,
    title = 'Welcome to Next.js',
}) => {
    return (
        <></>
    );
}

VFCはFunctionコンポーネントの型に関するモジュール

next.jsだと以下のように受け取るpropsのデータ型を定義しておくとよい模様

interface Props {
    children: ReactNode
    title: string
}

Layout.tsx return以下

return (
        <div className="flex flex-col justify-center min-h-screen text-gray-600 text-sm font-mono">
            <Head>
                <title>{title}</title>
            </Head>
            <header>
                <nav className="bg-gray-800 w-screen">
                    <div className="flex items-center pl-8 h-14">
                        <div className="flex space-x-4">
                            <Link href="/">
                                <a 
                                    data-testid="home-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    Home
                                </a>
                            </Link>
                            <Link href="/local-state-a">
                                <a 
                                    data-testid="makevar-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    makeVar
                                </a>
                            </Link>
                            <Link href="/hasura-main">
                                <a 
                                    data-testid="fetchpolicy-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    fetchPolicy(Hasura)
                                </a>
                            </Link>
                            <Link href="/hasura-crud">
                                <a 
                                    data-testid="crud-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    CRUD(Hasura)
                                </a>
                            </Link>
                            <Link href="/hasura-ssg">
                                <a 
                                    data-testid="ssg-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    SSG+ISR(Hasura)
                                </a>
                            </Link>
                            <Link href="/hooks-memo">
                                <a 
                                    data-testid="memo-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    custom hook + memo
                                </a>
                            </Link>
                        </div>
                    </div>
                </nav>
            </header>
            <main className="flex flex-1 flex-col justify-center items-center w-screen">
                {children}
            </main>
            <footer className="w-full h-12 flex justify-center items-center border-t">
                <a
                    href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
                    target="_blank"
                    rel="noopener noreferrer"
                >
                    Powered by{' '}
                    <span className="h-4 ml-2">
                        <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
                    </span>
                </a>
            </footer>
        </div>
    );

リンクにはdata-testidをつけておき、テスターが識別できるようにしておく



index.tsxの変更

import { VFC } from "react"
import { Layout } from "../components/Layout"

const Home: VFC = () => {
  return (
    <Layout title="Home">
      <p className="text-3xl font-bold">Next.js + GraphQL</p>
    </Layout>  
  )
}

export default Home

Home.module.cssの削除
pages/apiフォルダの削除

Apollo Client の利用

特徴1.

通常はReduxでuseState+useContextを使用してStoreでデータを一括管理できるようにStore(Provider)を自分で作成するが、Apollo Clientの場合は、cacheを自動的に作成してくれるためStore(Provider)をわざわざ作らなくてもよくなる
Apollo ClientではQuery @clientでcacheにアクセスできる

特徴2.

ローカルでStateを管理する方法についてのReduxとの違い
Redux(userContext)を利用する場合は商品が増えた場合などの変更時にdispatchを呼び出し、useSelectorで他のコンポーネントからデータにアクセスできるようにしている
Apollo Clientを利用する場合は、商品が増えた場合などの変更時はmakeVarを呼び出し、useReactiveVarで他のコンポーネントからデータにアクセスできるようにしている
makeVarで保存される領域はcacheとは独立したクライアントのローカル領域に保存される

特徴3

Apollo Clientは自動的にcache領域にデータを保存するが、それに加えてmakeVarでデータをローカルに保存して管理することもできる
cacheへのアクセスのためにはquery @clientを書く必要があるため、細かいクエリを書く手間を省きたい場合にmakeVarを使用することができる
また、cacheの更新の挙動などはマシン側にイニシアチブがあるため、自身の管理下でステートを制御したい場合にmakeVarを使用できる

makeVarを使用する

プロジェクト直下にcache.tsを作成する
基本形(cache.ts)

import { makeVar } from "@apollo/client";

interface Task {
    title: String
}

export const todoVar = makeVar<Task[]> ([])

Taskを管理するステートを作成


TaskをcacheとmakeVarで管理する際の両方の挙動を確認していく
タスクを作成していくコンポーネントとしてcomponents/LocalStateA.tsxを作成する
LocalStateA.tsx

import { ChangeEvent, FormEvent, useState, VFC } from "react";
import { todoVar } from "../cache";
import { useReactiveVar } from "@apollo/client";
import Link from "next/link";

export const LocalStateA: VFC = () => {
    const [input, setInput] = useState('')
    const todos = useReactiveVar(todoVar)

    const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        todoVar([...todoVar(), { title: input }])
        setInput('')
    }

    return (
        <>
            <p className="mb-3 font-bold">makeVar</p>
            {todos?.map((task, index) => {
                return (
                    <p className="mb-3 y-1" key={index}>
                        {task.title}
                    </p>
                )
            })}
            <form 
                className="flex flex-col justify-center items-center"
                onSubmit={handleSubmit}
            >
                <input 
                    className="mb-3 px-3 py-2 border border-gray-300"
                    placeholder="New task ?"
                    value={input}
                    onChange={(e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value)}
                />
                <button
                    disabled={!input}
                    className="disabled:opacity-40 mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                    type="submit"
                >
                    Add new state
                </button>
            </form>
            <Link href="/local-state-b">
                <a>Next</a>
            </Link>
        </>
    )
}


※tailwindでopacityを使用する場合はtailwind.config.jsのextendにopacityを追記する必要がある

extend: { opacity: ['disabled'] },


次にStateの状態をほかのコンポーネントからも利用できるか確認するためのcomponents/LocalStateB.tsxを作成する
LocalStateB.tsxでは更新されたステートを読みこむコンポーネントとする

import { ChangeEvent, FormEvent, useState, VFC } from "react";
import { todoVar } from "../cache";
import { useReactiveVar } from "@apollo/client";
import Link from "next/link";

export const LocalStateB: VFC = () => {
    const todos = useReactiveVar(todoVar)
    return (
        <>
            {todos?.map((task, index) => {
                return (
                    <p className="mb-3 y-1" key={index}>
                        {task.title}
                    </p>
                )
            })}
            <Link href="/local-state-a">
                <a>Back</a>
            </Link>
        </>
    );
}


StateA, StateBのpageを作成する
local-state-a.tsx

import { VFC } from "react"
import { LocalStateA } from "../components/LocalStateA"
import { Layout } from "../components/Layout"

const LocalStatePageA: VFC = () => {
  return (
    <Layout title="Local State A">
        <LocalStateA />
    </Layout>  
  )
}

export default LocalStatePageA

local-state-b.tsx

import { VFC } from "react"
import { LocalStateB } from "../components/LocalStateB"
import { Layout } from "../components/Layout"

const LocalStatePageB: VFC = () => {
  return (
    <Layout title="Local State B">
        <LocalStateB />
    </Layout>  
  )
}

export default LocalStatePageB

TastAで追記した値がTaskBからも取得できていることがわかる
f:id:shinseidaiki:20211119162825p:plain
f:id:shinseidaiki:20211119162901p:plain

Hasura next.js連携

libフォルダ作成
その直下にapolloClient.tsを作成
apolloClient.tsxは公式による作成例がGitHubで公開されている
https://github.com/vercel/next.js/blob/canary/examples/with-apollo/lib/apolloClient.js

使用法上の注意

next.jsはSSG + サーバーサイドレンダリング + IncrementalStaticGenerationがあって、サーバーサイドで実行される処理とクライアント側で実行されるJSの処理が混在している
そのため、作成するapolloClientはサーバー側とクライアント側とで切り分ける必要がある
特に、SSGやSSRのようなサーバーサイド側で処理されるもの(getStaticProps, getStaticPaths)でアポロクライアントを使う場合は毎回アポロクライアントのインスタンスを作成する必要がある
クライアント側で処理されるもの(jsで実行されるqueryの発行)に関しては一度アポロクライアントを作成しておけばよいという実装になっている
ここの処理が適切でない場合はruntimeエラーが発生する

基本的には公式サンプルのコピペで実装を行う

基本形(apolloClient.ts)

import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
import 'cross-fetch/polyfill'

export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'

let apolloClient: ApolloClient<NormalizedCacheObject> | undefined

const createApolloClient= () => {
    return new ApolloClient({
      ssrMode: typeof window === 'undefined',
      link: new HttpLink({
        uri: 'https://[YOUR-HASURA-DB-NAME].hasura.app/v1/graphql',
      }),
      cache: new InMemoryCache(),
    })
}

let apolloClient: ApolloClient | undefinedはアポロクライアントがない場合はundefinedになる
ssrMode: typeof window === 'undefined', のwindowsがundefinedでない場合はブラウザで実行しているという意味すなわちクライアントの処理であり、undefinedである場合はサーバーサイドの処理であることを差している
linkのuriにはHasuraHasura CloudのエンドポイントのURLを入れる


基本形2(apolloClient.ts)- initializeApolloの追加

export const initializeApollo = (initialState = null) => {
    const _apolloClient = apolloClient ?? createApolloClient()  
    // For SSG and SSR always create a new Apollo Client
    if (typeof window === 'undefined') return _apolloClient
    // Create the Apollo Client once in the client
    if (!apolloClient) apolloClient = _apolloClient
  
    return _apolloClient
}

この処理を入れることでサーバーサイドの場合は毎回アポロクライアントが生成され、クライアントの場合は一度生成したものを使いまわす処理ができる
サーバーサイドの処理の場合はlet apolloClient: ApolloClient | undefinedのapolloClientが毎回undefinedとなり、 const _apolloClient = apolloClient ?? createApolloClient() の処理で毎回生成される形となる
クライアントの処理の場合は const _apolloClient = apolloClient ?? createApolloClient() で一度作成されたapolloClient を使いまわす処理となるためにこの実装で、切り分ける処理が実装できた

apolloClientをプロジェクトの中で作成する

_app.tsxを編集してアポロクライアントを作成する
追記する部分(_app.tsx)

import { ApolloProvider } from '@apollo/client'
import { initializeApollo } from '../lib/apolloClient'

function MyApp({ ................................) {
  const client = initializeApollo()
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  )

_app.tsxのComponentをApolloProviderで囲うことによってプロジェクトのあらゆる場所でアポロプロバイダーを利用することができるようになる

Hasuraからデータを取得してアポロクライアントを介してデータを処理する

hasura-main.tsxをpagesフォルダ下に作成

import { VFC } from "react";
import Link from "next/link";
import { useQuery } from "@apollo/client";
import { GET_USERS } from "../queries/queries";
import { GetUsersQuery } from "../types/generated/graphql";
import { Layout } from "../components/Layout";

const FetchMain: VFC = () => {
    const { data, error } = useQuery<GetUsersQuery>(GET_USERS)
    if (error) {
        return (
            <Layout title="Hasura fetchPolicy">
                <p>Error: {error.message}</p>
            </Layout>
        )
    }
    return (
        <Layout title="Hasura fetchPolicy">
            <p className="mb-6 font-bold">Hasura main page</p>
            {data?.users.map((user) => {
                return (
                    <p className="my-1" key={user.id}>
                        {user.name}
                    </p>
                )
            })}
            <Link href="/hasura-sub">
                <a className="mt-6">Next</a>
            </Link>
        </Layout>
    )
}

export default FetchMain

import { useQuery } from "@apollo/client";はアポロクライアントがクエリを生成するために使用
import { GET_USERS } from "../queries/queries";をインポートすることでユーザー情報を取得するクエリを使用できる
import { GetUsersQuery } from "../types/generated/graphql"; はユーザークエリの型である
実際の型名はgraphql.tsxに記述されているが、queries.tsファイルのquery xxxxのxxxxの末尾にQueryをつけると型名になる
f:id:shinseidaiki:20211119173638p:plain

GetUsersQueryの構造はgraphql.tsxで確認できる

export type GetUsersQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, name: string, created_at: any }> };

useQuery(GET_USERS)は引数に作成したクエリを選択する

動作確認

fecthPolicyのメニューを選択してユーザーが取得できていればOK
データは以下のような形にデフォルトでフォーマットされる
__typenameが付与される

{users: Array(3)}
users: Array(3)
0: {__typename: 'users', id: '5d4df...................', name: 'user3', created_at: '2021-11-17T13:47:46.832885+00:00'}
1: {__typename: 'users', id: '13399...................', name: 'user2', created_at: '2021-11-17T13:47:36.44969+00:00'}
2: {__typename: 'users', id: 'bcsce.....................', name: 'user1', created_at: '2021-11-17T13:47:23.308833+00:00'}
length: 3

取得したデータのキャッシュにアクセスする際は__typenameとidをkeyにしてアクセスするメカニズムとなっている

ApolloClientでキャッシュ(Cache)のデータを参照する

hasura-sub.tsxを作成
@clientを使用しない実装と使用する(キャッシュを使用する)実装の動作の違いを確認する


その1
@clientを使用しない実装における動作
const { data } = useQuery(GET_USERS)をGET_USERとする
useQueryはfetchPolicyを設定することができ、デフォルトでcache firstの設定が有効になっており、キャッシュが存在する場合は常にキャッシュの値を参照しに行くという処理を行うようになっている
今回は挙動確認のためにnetwork-onlyにしておく(毎回データをサーバーに取得しにいく)

import { VFC } from "react";
import Link from "next/link";
import { useQuery } from "@apollo/client";
import { GET_USERS_LOCAL, GET_USERS } from "../queries/queries";
import { GetUsersQuery } from "../types/generated/graphql";
import { Layout } from "../components/Layout";

const FetchSub: VFC = () => {
    const { data } = useQuery<GetUsersQuery>(GET_USERS, {
        fetchPolicy: 'network-only'
    })
    return (
        <Layout title="hasura fetchPolicy read cache">
            <p className="mb-6 font-bold">Hasura main page</p>
            {data?.users.map((user) => {
                return (
                    <p className="my-1" key={user.id}>
                        {user.name}
                    </p>
                )
            })}
            <Link href="/hasura-main">
                <a className="mt-6">Back</a>
            </Link>
        </Layout>
    ) 
}

export default FetchSub
動作確認

自作したhasura main pageのページにアクセスしてF12のnetwork>XHRを選択して再読み込みを実施
graphqlが確認でき、これはクライアントからGraphQLサーバーにフェッチが行われている処理であり、想定通りGraphQLサーバーからデータを取得している
f:id:shinseidaiki:20211119181536p:plain
subページも想定通りの挙動、graphqlを確認することができる


その2
@clientを使用する実装における動作
const { data } = useQuery(GET_USERS)をGET_USERS_LOCALに変更してfetchPolicyはデフォルトに戻す

const { data } = useQuery<GetUsersQuery>(GET_USERS_LOCAL)
動作確認

graphQLサーバーにアクセスしていない状態でもデータが表示されていることが確認できる

4種類のFetch Policyについて

FetchPolicyには以下の4種類が存在

useQuery<GetUsersQuery>(QUERY_NAME, {
        fetchPolicy: 'network-only',
        fetchPolicy:'cache-and-network',
        fetchPolicy:'cache-first',
        fetchPolicy:'no-cache',
    })

デフォルトでは'cache-first',が選択される
'cache-first' 常に最初に取得したデータしか読み取らないため、サーバーサイドの更新データを反映することができず、頻繁にサーバー側のデータが更新されるようなサイトには不向き
'no-cache' キャッシュが作られない
動作確認としてsubの方は何も表示されないことが確認できる
mainは毎回サーバーにデータを取得しに行く
f:id:shinseidaiki:20211119194619p:plain
'network-only'は必ずサーバー側にフェッチしに行く
取得中はなにも表示しない
'cache-and-network'も必ずサーバー側にフェッチに行く
取得中はキャッシュの内容を表示しておく

基本的には'cache-and-network'を設定しておくとよいと考えられる


Hasura CRUDの実装

pages直下にhasura-crud.tsxを作成する
hasura-crud.tsx

import { VFC, useState, FormEvent } from "react"
import { useQuery, useMutation } from "@apollo/client"
import { GET_USERS, CREATE_USER, UPDATE_USER, DELETE_USER } from "../queries/queries"
import { GetUsersQuery, CreateUserMutation, UpdateUserMutation, DeleteUserMutation } from "../types/generated/graphql"
import { Layout } from "../components/Layout"
import { UserItem } from "../components/UserItem"

const HasuraCRUD: VFC = () => {
    const [editedUser, setEditedUser] = useState({id: '', name:''})
    const { data, error } = useQuery<GetUsersQuery>(GET_USERS, {
        fetchPolicy:'cache-and-network',
    })
    const [update_users_by_pk] = useMutation<UpdateUserMutation>(UPDATE_USER)
    const [insert_users_one] = useMutation<CreateUserMutation>(CREATE_USER, {
        update(cache, {data: {insert_users_one} }) {
            const cacheId = cache.identify(insert_users_one)
            cache.modify({
                fields: {
                    users(existingUsers, {toReference}) {
                        return [toReference(cacheId), ...existingUsers]
                    },
                },
            })
        },
    })
    const [delete_users_by_pk] = useMutation<DeleteUserMutation>(DELETE_USER, {
        update(cache, {data: {delete_users_by_pk} }) {
            cache.modify({
                fields: {
                    users(existingUsers, {readField}) {
                        return existingUsers.filter(
                            (user) => delete_users_by_pk.id !== readField('id', user)
                        )
                    },
                },
            })
        },
    })
    const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        if (editedUser.id) {
            try {
                await update_users_by_pk({
                    variables: {
                        id: editedUser.id,
                        name: editedUser.name,
                    },
                })
            } catch (err) {
                alert(err.message)
            }
            setEditedUser({ id: '', name: ''})
        } else {
            try {
                await insert_users_one({
                    variables: {
                        name: editedUser.name,
                    },
                })
            } catch (err) {
                alert(err.message)
            }
            setEditedUser({ id: '', name: ''})
        }
    }
    if (error) return <Layout title="HasuraCRUD">Error: {error.message}</Layout>

    return (
        <Layout title="Hasura CRUD">
            <p className="mb-6 font-bold">Hasura CRUD</p>
            <form 
                className="flex flex-col justify-center items-center"
                onSubmit={handleSubmit}
            >
                <input
                    className="px-3 py-2 border border-gray-300" 
                    type="text"
                    placeholder="New user"
                    value={editedUser.name}
                    onChange={(e) => setEditedUser({ ...editedUser, name: e.target.value})}
                />
                <button 
                    disabled={!editedUser.name}
                    className="disabled:opacity-40 mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                    data-testid="new"
                    type="submit"
                >
                    {editedUser.id ? 'Update' : 'Create'}
                </button>
            </form>

            {data?.users.map((user) => {
                return (
                    <UserItem 
                        key={user.id}
                        user={user}
                        setEditedUser={setEditedUser}
                        delete_users_by_pk={delete_users_by_pk}
                    />
                )
            })}
        </Layout>
    )
}

export default HasuraCRUD

CREATEとDELETEの場合は処理終了後に自動的にキャッシュが更新されない使用になっている(UPDATEは自動で更新される)
データ生成・削除の場合はキャッシュを更新させたい場合は自身で明示的にキャッシュの更新をさせる処理を記載する必要がある

データ生成・削除処理終了後に返ってくるデータはqueries.tsのクエリの関数名をテーブル名として返してくる
insert_users_oneがテーブル名として返ってくる
フィールドにはid, name, created_atの情報が記載されたデータが返ってくる

export const CREATE_USER = gql`
    mutation CreateUser($name: String!) {
        insert_users_one(object: { name: $name }) {
            id
            name
            created_at
        }
    }
`

データ生成後のキャッシュ更新処理において
update(cache, {data: {insert_users_one} }) { の処理は返ってきたinsert_users_oneのデータ(作成したユーザーデータ)を読みに行っており、
const cacheId = cache.identify(insert_users_one)の処理はアポロの機能であり、insert_users_one(作成したユーザーデータ)のkeyを受け取っている
toReference(cacheId)に作成したユーザーデータのIDを渡すことでIDに紐づいたinsert_users_one(作成したユーザーデータ)のデータを参照することができる
cache.modify({ fields: { は更新したいキャッシュのフィールドを記載するところであり、usersをここでは選択している
users(existingUsers, {toReference}) {
return [toReference(cacheId), ...existingUsers]
} の処理で新規作成ユーザー+既存のユーザーを配列に足してあげことで、キャッシュに格納するという処理を行ってキャッシュを更新している

データ削除の場合は、フィルターで削除したフィールドだけをキャッシュからそぎ落とす方法で消している
(※実装の方式としては今削除したユーザーと一致しないものは残すというやり方でそぎ落としている)
readFieldを使うと任意のfieldの値を読み取ることができる
users(existingUsers, {readField}) {
return existingUsers.filter(
(user) => delete_users_by_pk.id !== readField('id', user)
)
},

作成・更新クエリでデータを渡す場合はvariablesで渡すことができる

await update_users_by_pk({
    variables: {
        id: editedUser.id,
        name: editedUser.name,


上記のhasura-crudにユーザー一覧情報を表示させたいのでcomponentsにUserItem.tsxを作成している
UserItem.tsx

import { VFC, memo, Dispatch, SetStateAction } from "react";
import { Users, DeleteUserMutationFn } from "../types/generated/graphql";

interface Props {
    user: {
        __typename?: 'users'
    } & Pick<Users, 'id' | 'name' | 'created_at'>
    delete_users_by_pk: DeleteUserMutationFn
    setEditedUser: Dispatch<
        SetStateAction<{
            id: String
            name:String
        }>
    >
}

export const UserItem: VFC<Props> = ({user, delete_users_by_pk, setEditedUser}) => {
    return (
        <div className="my-1">
            <span className="mr-2">{user.name}</span>
            <span className="mr-2">{user.created_at}</span>
            <button 
                className="mr-1 py-1 px-3 text-white bg-green-600 hover:bg-green-700 rounded-2xl focus:outline-none"
                data-testid={`edit-${user.id}`}
                onClick={() => {
                    setEditedUser(user)
                }}    
            >
                Edit
            </button>
            <button
                className="mr-1 py-1 px-3 text-white bg-pink-600 hover:bg-pink-700 rounded-2xl focus:outline-none"
                data-testid={`delete-${user.id}`}
                onClick={async () => {
                    await delete_users_by_pk({
                        variables: {
                            id: user.id,
                        },
                    })
                }}    
            >
                Delete
            </button>
        </div>
    )
}

Dispatch, SetStateActionはuseStaetの更新用の関数のデータ型で使用する
DeleteUserMutationFnは削除用関数のデータ型

UserItem.tsxのinterface Propsに関して、
userのデータ型は__typenameとid, name, created_atだけをピックしたUsers(カーソルをホバーすると他にもいろいろなデータがUsersクラスには存在している)の二つのデータが格納されている
f:id:shinseidaiki:20211120011743p:plain
delete_user_by_pkはDeleteUserMutationにFnをくっつけたものがデータ型になる
setEditedUserはhasura-crudで設定したuseStateのsetEditedUserにカーソルをホバーして出てきたデータ型をコピペしている
f:id:shinseidaiki:20211120012307p:plain

動作確認

Hasura CRUDのページでCRUDができることを確認
フォームに文字を入力するとデータの数だけレンダリングが行われてしまう(例:データが4個だと4回レンダリングされる)
f:id:shinseidaiki:20211120014219p:plain
f:id:shinseidaiki:20211120014206p:plain

無駄なレンダリングの対処法
→ インポートしたmemo機能を使用する
親コンポーネントhasura-crudのinputフォームにおける文字入力に対して、子コンポーネントUserItemのsetEditedUserが反応して再レンダリングしてしまっている
→親コンポーネントの変更に対して子コンポーネントが再レンダリングされるのはデフォルトの挙動であるが、memoを子コンポーネントにかませることで再レンダリングさせないようにすることが可能になる
→UserItem.tsxのファンクショナルコンポーネントをmemoでラップする

元々UserItem.tsx

export const UserItem: VFC<Props> = ({user, delete_users_by_pk, setEditedUser}) => {
.......
}

メモでラップUserItem.tsx

export const UserItem: VFC<Props> = memo(({user, delete_users_by_pk, setEditedUser}) => {
.........
})
トラブルシュート

テストファイルに以下追加

/**

@jest-environment jsdom

/

Nextjs ver11.0対応
Layout component
1. Layout componentに Imageのimport文追加
2. をNextの表記に変更



Next.js + Hasuraによる Stastic Site Generation + Increamental Static Regenerationの実装

pages/hasura-ssg.tsxを作成
必要なモジュールのインポート

import { VFC } from "react";
import Link from "next/link";
import { GetStaticProps } from "next";
import { initializeApollo } from "../lib/apolloClient";
import { GET_USERS } from "../queries/queries";
import { GetUsersQuery, Users } from "../types/generated/graphql";
import { Layout } from "../components/Layout";

アプリのビルド時にサーバーサイドで実行される処理であるgetStaticPropsの作成
revalidateを1秒にしてインクリメンタルスタティックレジェネレーションも有効化
アポロクライアントを生成してアポロクライアントを使用してクエリを投げる
クエリで返ってくるユーザーのデータはdataとして受け取り、ファンクショナルコンポーネントのpropsにusersとしてデータが取り出せるようにしておく

export const getStaticProps: GetStaticProps = async () => {
    const apolloClient = initializeApollo()
    const { data } = await apolloClient.query<GetUsersQuery>({
        query: GET_USERS,
    })
    
    return {
        props: { users: data.users },
        revalidate: 1,
    }
}

ファンクショナルコンポーネントの実装

interface Props {
    users: ({
        __typename?: "users";
    } & Pick<Users, 'id' | 'name' | 'created_at'>)[]
}

const HasuraSSG: VFC<Props> = ({ users }) => {
    return (
        <Layout title="Hasura SSG">
            <p className="mb-3 font-bold">SSG+ISR</p>
            {users?.map((user) => {
                return (
                    <Link key={user.id} href={`/users/${user.id}`}>
                        <a className="my-1 cursor-pointer" data-testid={`link-${user.id}`}>
                            {user.name}
                        </a>
                    </Link>    
                )
            })}
        </Layout>
    )
}

export default HasuraSSG


interfaceのusersの型は例のごとくカーソルを合わせて取得する
f:id:shinseidaiki:20211120145728p:plain
ただし、user内のid, name, created_atは適宜ない場合があるので、Pickを用いて書き換えている
__typename?: "users"とUsersオブジェクトで構成された配列型をusersとしている

注意:SSG + ISR動作確認

SSGやISRの動作確認を行う場合はyarn devではなく

yarn build 
yarn start

で実行する必要がある
yarn devはサーバーサイドレンダリングを常に行うようになっているため

トラブルシュート

yarn build時の missing display name errorが発生した場合はUserItem.tsxの関数定義の上にESLint無効化の一行を追加
// eslint-disable-next-line react/display-name を追加
memo化している箇所の1行上に記載する

example UserItem.tsx

// eslint-disable-next-line react/display-name 
export const UserItem: VFC<Props> = memo(

example Child.tsx

// eslint-disable-next-line react/display-name
export const Child: VFC<Props> = memo(({ printMsg, handleSubmit }) => {


Hasura SSG画面にて、SSGが起動しているため、JavaScriptを無効化してもデータが取得できることも確認できる
F12>Setting>Debugger >Disabled JS
なお、Fetch Policyはサーバーにデータを取得しに行っているので、JS無効化の状態ではデータが何も映らないことが確認できる


hasura-ssgから個別のユーザーに飛ぶ画面の作成
pages/users/[id].tsxを作成する

[id].tsx getStaticPathsの実装

export const getStaticPaths: GetStaticPaths = async () => {
    const apolloClient = initializeApollo()
    const { data } = await apolloClient.query<GetUserIdsQuery>({
        query: GET_USERIDS,
    })
    const paths = data.users.map((user) => ({
        params: {
            id: user.id
        },
    }))
    
    return {
        paths,
        fallback: true,
    }
}


以下の記述はNext.jsで個別ページを作る際のお決まりの実装方式

const paths = data.users.map((user) => ({
        params: {
            id: user.id
        },
    }))

なおこの時ユーザーIDはanyになっているので、graphql.tsxの17行目あたりのuuidがanyとなってしまっているので、stringに書き換える
f:id:shinseidaiki:20211120153023p:plain

export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
timestamptz: any;
uuid: any;
};

のuuid: any;を以下に変更

uuid: string;


[id].tsx getStaticPropsの実装

export const getStaticProps: GetStaticProps = async ({params}) => {
    const apolloClient = initializeApollo()
    const { data } = await apolloClient.query<GetUserByIdQuery>({
        query: GET_USERBY_ID,
        variables: { id: params.id },
    })
    return {
        props: {
            user: data.users_by_pk,
        },
        revalidate: 3,
    }
}

return {props: { user: data.users_by_pk, の data.users_by_pk のフィールドの名前は queries.ts の users_by_pk(id: $id) に対応している


[id].tsx のファンクショナルコンポーネントの実装

interface Props {
    user: {
        __typename?: 'users'
    } & Pick<Users, 'id' | 'name' | 'created_at'>
}

const UserDetail: VFC<Props> = ({ user }) => {
    if (!user) return <Layout title="loading">Loading...</Layout>
    
    return (
        <Layout title={user.name}>
            <p className="text-xl font-bold">User detail</p>
            <p className="m-4">
                {'ID : '}
                {user.id}
            </p>
            <p className="mb-4 text-xl font-bold">{user.name}</p>
            <p className="mb-12">{user.created_at}</p>
            <Link href="/hasura-ssg">
                <div className="flex cursor-pointer mt-12">
                    <ChevronDoubleLeftIcon 
                        data-testid="auth-to-main"
                        className="h-5 w-5 mr-3 text-blue-500"
                    />
                    <span data-testid="back-to-main">Back to main-ssg-page</span>
                </div>
            </Link>
        </Layout>
    )
}

export default UserDetail
動作確認

SSGとISRが[id].tsxでもうまくいっていることが確認できる

Custom Hooks + useCallback + memoの実装

hasura-crud.tsxファイルは現状CRUDが混在しておりコード保守性がよくないため、Custom Hooksを使って保守性を高めていく
hooksフォルダを作成

その直下にまずはユーザー作成用Custom Hooksの useCreateForm.tsを作成する
Providerのような実装の形式になる
useCreateForm.tsに必要なモジュールをインポート

import { useState, useCallback, ChangeEvent, FormEvent } from "react";
import { useMutation } from "@apollo/client";
import { CREATE_USER } from "../queries/queries";
import { CreateUserMutation } from "../types/generated/graphql";

useCreateForm.ts ファンクショナルコンポーネントの作成

export const useCreateForm = () => {
    const [text, setText] = useState('')
    const [username, setUsername] = useState('')

    const [insert_users_one] = useMutation<CreateUserMutation>(CREATE_USER, {
        update(cache, {data: {insert_users_one} }) {
            const cacheId = cache.identify(insert_users_one)
            cache.modify({
                fields: {
                    users(existingUsers, {toReference}) {
                        return [toReference(cacheId), ...existingUsers]
                    },
                },
            })
        },
    })

    const handleTextChange = (e: ChangeEvent<HTMLInputElement>) => {
        setText(e.target.value)
    }
    const usernameChange = (e: ChangeEvent<HTMLInputElement>) => {
        setUsername(e.target.value)
    }
    const printMsg = () => {
        console.log('Hello')
    }

    const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        try {
            await insert_users_one({
                variables: {
                    name: username,
                },
            })
        } catch (err) {
            alert(err.message)
        }
        setUsername('')
    }

    return {
        text,
        handleSubmit,
        username,
        usernameChange,
        printMsg,
        handleTextChange,
    }
}

const [insert_users_one] はhasura-crud.tsx で作成したコードをそのまま持ってくる
const handleSubmit に関しても同様にコードを拝借する


次にcomponentsフォルダに CreateUser.tsx を作成する
このコンポーネントではまず Custom Hooks CreateUser で作成していたものをすべて受け取る
フォームのコンポーネントも作成する

CreateUser.tsx

import { VFC } from "react"
import { useCreateForm } from "../hooks/useCreateForm"

export const CreateUser: VFC = () => {
    const {
        text,
        handleSubmit,
        username,
        usernameChange,
        printMsg,
        handleTextChange,
    } = useCreateForm()
    
    return (
        <>
            {console.log('CreateUsr rendered')}
            <p className="mb-3 font-bold">Custom Hook + useCallback + memo</p>
            <div className="mb-3 flex flex-col justify-center items-center">
                <label>Text</label>
                <input 
                    className="px-3 py-2 border border-gray-300"
                    type="text"
                    value={text}
                    onChange={handleTextChange}
                />
            </div>
            <form 
                className="px-3 py-2 border border-gray-300"
                onSubmit={handleSubmit}
            >
                <label>Username</label>
                <input 
                    className="px-3 py-2 border border-gray-300"
                    placeholder="New user ?"
                    type="text"
                    value={username}
                    onChange={usernameChange}
                />
                <button 
                    className="mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                    type="submit"
                >
                    Submit
                </button>
            </form>
        </>
    )
}

export default CreateUser


そして useCallback の最適化についての効果を確認するためにChild.tsxをcomponentsに作成する
このChild.tsxは親コンポーネントのCreateUserからprintMsgがわたされている
Child.tsx

import { ChangeEvent, FormEvent, memo, VFC } from "react"

interface Props {
    printMsg: () => void
}

export const Child: VFC<Props> = ({ printMsg }) => {
    return (
        <>
            {console.log('Child rendered')}
            <p>Child Component</p>
            <button 
                className="mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                onClick={printMsg}
            >
                Click
            </button>
        </>
    )
}


親コンポーネントの CreateUser.tsx にChild.tsx を表示させる
必要なモジュールインポート CreateUser.tsx

import { Child } from "./Child"

CreateUser.tsx ファンクショナルコンポーネントの最後に以下追記

<Child printMsg={printMsg} />
||< 


CreateUserのコンポーネントをpageに埋め込んでいくために hooks-memo.tsx ファイルを作成する
hooks-memo.tsx
>||
import { VFC } from "react"
import { Layout } from "../components/Layout"
import CreateUser from "../components/CreateUser"

const HooksMemo: VFC = () => {
    return (
        <Layout title="Hooks memo">
            <CreateUser />
        </Layout>
    )
}

export default HooksMemo


ここまで実装が済めば動作確認

yarn dev

この実装ではテキストフィールドに文字を入力するごとに入力とは関係のないChild.tsxコンポーネントが影響を受けて再レンダリングを実施してしまう(Next.jsの仕様)
f:id:shinseidaiki:20211120164525p:plain

→ 対処法:Childをmemoで囲いuseCallbackを利用する
なおビルド時にエラーが発生するためESLintの無視のコードも記載する

元々Child.tsx

export const Child: VFC<Props> = ({ printMsg }) => {
    .....................
}

memo 適用後 Child.tsx

// eslint-disable-next-line react/display-name
export const Child: VFC<Props> = memo( ({ printMsg }) => {
      .....................................
} )

memoで囲うだけでは再レンダリングは止まらないため、useCallbackを次に実装していく
これは子コンポーネントはmemo化されているが、親コンポーネントCreateUser.tsxで渡してくるprintMsg自体がメモ化されていないことに起因する
毎回新しい関数オブジェクトが生成されている形になっているため、propsの値が毎回変化しているとみなされてChildコンポーネントが親コンポーネントの方で毎回レンダリングされてしまっている
printMsgを毎回生成しないようにするためにuseCallbackを使用する
useCreateForm.ts のprintMsgに useCallback の処理を追記する

useCreateForm.ts 元々

const printMsg = () => {
        console.log('Hello')
    }

useCreateForm.ts 変更後

const printMsg = useCallback( () => {
    console.log('Hello')
}, [])

ユーズコールバックはこれが基本形 useCallback(() => {} , )には依存関係を表しておりuseEffectと同様の書き方ができる
[]の場合は初回呼び出し時にのみ読み込まれる

動作確認

yarn dev
このように実装することで子コンポーネントの再レンダリングは行われなくなることが確認できる
文字を入力しても Child rendered が出力されていなければよい

Tips!!!!

カスタムフック Custom Hooks で定義されている関数はuseCallbackを適用することが推奨されている
基本的にはCustom Hooksは他の多数のコンポーネントに多用することの方が多いと考えられるため

動作検証2

useCallbackでラップしていないhandleSubmitを子コンポーネントに渡してみる
CreateUser

<Child printMsg={printMsg} handleSubmit={handleSubmit}/>

Child

interface Props {
    ..................
    handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
}

export const Child: ....................mo(({ ...........sg, handleSubmit }) => {
   ......................

この処理を実装してしまうとまたしても文字入力ごとに Child rendered が表示されて、子コンポーネントが読み込まれていることがわかる
よって、useCreateFormで使われているCustom Hooksの関数は基本的にはuseCallBack化しておくことが推奨される

handleSubmitをuseCallback化する

注意しなければいけないのはステートがある場合でも、を何も考えずにで設定すると、このケースのようにusernameが文字を入力されても初期状態の空文字''から変更できないという現象が起こってしまう
※名前のないユーザしか作れなくなってしまう
usernameのようなステートがuseCallback内にある場合、[username]を設定しておく必要がある

useCreateForm.ts handleSubmit元々

    const handleSubmit =  async (e: FormEvent<HTMLFormElement>) => {
     .............
          name: username,
      ...............
      setUsername('')
    }

useCreateForm.ts handleSubmit 変更後

    const handleSubmit =  useCallback(  async (e: FormEvent<HTMLFormElement>) => {
     ....................
         name: username,
    .........................
         setUsername('')
    }, [username] )
動作確認

Textに文字を入れた場合は子コンポーネントの再レンダリングは止まり、
Usernameに文字を入れた場合は子コンポーネントの再レンダリングは起こる状態となることを確認できれば良い
usenameはChildに関わっているパラメータであるため想定通りの挙動となる
f:id:shinseidaiki:20211120172930p:plain

他の関数の修正

useCreateForm.ts

const handleTextChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setText(e.target.value)
    }, [])
    const usernameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setUsername(e.target.value)
    }, [])

Custom Hooksを使用するメリット

CreateUser.tsx は画面
useCreateForm.tsはロジックという風に分けることができるため保守性に優れている

React-Testing-library with GraphQLを利用したTestの実装

テストの実装(クエリのMock化

mockフォルダをプロジェクト直下に作成
mock/handlers.tsを作成

mock service workerからgraphQLをインポートする
mock service workerはrestAPIとGraghQLの両方に対応している
queries.tsで作成したクエリをテスト用に作成していく

必要モジュール

import { graphql } from 'msw'

handlers.ts ユーザ取得クエリのモック化(GetUsers)

export const handlers = [
    graphql.query('GetUsers', (req, res, ctx) => {
        return res(
            ctx.data({
                users: [
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000a',
                        name: 'Test User A',
                        created_at: '2021-01-01T13:47:23.308833+00:00'
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000b',
                        name: 'Test User B',
                        created_at: '2021-02-02T13:47:23.308833+00:00'
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000c',
                        name: 'Test User C',
                        created_at: '2021-03-03T13:47:23.308833+00:00'
                    },
                ],
            })
        )
    }),
]

このように書くとテストの際にgetUserを呼び出した場合は、queries.tsでGetUsersを使っている部分を呼び出すのではなく、ここに記述したテストユーザーのデータを返すように自動的にMock化することができる


handlers.ts モック化 GetUserIDs と GetUserById

graphql.query('GetUserIDs', (req, res, ctx) => {
        return res(
            ctx.data({
                users: [
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000a',
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000b',
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000c',
                    },
                ],
            })
        )
    }),
graphql.query('GetUserById', (req, res, ctx) => {
        const { id } = req.variables
        if (id === 'b39ec6cc-9d85-458d-aa0e-00000000000a') {
            return res(
                ctx.data({
                    users_by_pk: {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000a',
                        name: 'Test User A',
                        created_at: '2021-01-01T13:47:23.308833+00:00',
                    }
                })
            )
        }
        if (id === 'b39ec6cc-9d85-458d-aa0e-00000000000b') {
            return res(
                ctx.data({
                    users_by_pk: {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000b',
                        name: 'Test User B',
                        created_at: '2021-02-02T13:47:23.308833+00:00',
                    }
                })
            )
        }
        if (id === 'b39ec6cc-9d85-458d-aa0e-00000000000c') {
            return res(
                ctx.data({
                    users_by_pk: {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000c',
                        name: 'Test User C',
                        created_at: '2021-03-03T13:47:23.308833+00:00',
                    }
                })
            )
        }
    }),

モック化した関数も const { id } = req.variables で引数を受け取ることができる


テストの実装(テストファイル・テストケースの実装

__test__フォルダに正しく画面遷移が行われるかどうかをチェックするNavBar.test.tsxを作成

テスト基本形

テストに必要なモジュールのインストール

import { render, screen, cleanup } from "@testing-library/react"
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event'
import { getPage, initTestHelpers } from 'next-page-tester'
import { setupServer } from 'msw/node'
import { handlers } from "../mock/handlers"

initTestHelpers()

const server = setupServer(...handlers)

beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

userEventはユーザーのクリックイベントをシミュレーションするためのライブラリ
next-page-testerを使用する場合はinitTestHelpers()で初期化が必要
const server = setupServer(...handlers)はテスト用のモックサーバーを立てている
before ... after ... は各テストケース開始時にモックサーバーを立てて、終了時にモックサーバーをお片付けする処理


テストケースの基本形

NavBar.test.tsx

describe('Navigation Test Case', () => {
    it ('Should route to selected page in navbar', async () => {
       const { page } = await getPage({
           route: '/',
       })
       render(page)
       expect(await screen.findByText('Next.js + GraphQL')).toBeInTheDocument()
    })
})
テストの実行
yarn test

テストが通ればOK

トラブルシュート

yarn testの実行が終わらない場合
Next 12をNext11にダウングレードする

yarn add next@11.1.2
トラブルシュート

ReferenceError: setImmediate is not defined 対処法
Jest系のupdateが原因の模様

1. ReferenceError: document is not defined
対処法 : 各テストファイルの先頭に下記3行のコメント文を追加

/**
* @jest-environment jsdom
*/


2. ReferenceError: setImmediate is not defined
対処法 :

1. setimmdiate パッケージのインストール

yarn add setimmediate

2. 各テストファイルのimport部に下記importを追加

import 'setimmediate'
テストケース作成の続き

NavBar.test.tsx のクリックイベントによる画面遷移の確認

describe('Navigation Test Case', () => {
    it ('Should route to selected page in navbar', async () => {
       ...............
       userEvent.click(screen.getByTestId('makevar-nav'))
       expect(await screen.findByText('makeVar')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('fetchpolicy-nav'))
       expect(await screen.findByText('Hasura main page')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('crud-nav'))
       expect(await screen.findByText('Hasura CRUD')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('ssg-nav'))
       expect(await screen.findByText('SSG+ISR')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('memo-nav'))
       expect(await screen.findByText('Custom Hook + useCallback + memo')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('home-nav'))
       expect(await screen.findByText('Next.js + GraphQL')).toBeInTheDocument()
    })
})

クリックイベントはuserEvent.clickで実行できる
クリックしたい要素はLayout.tsx で定義した data-testid="makevar-nav" の値を使用して識別している
クリックイベントのテストを行う場合はdata-testidの実装を忘れずに行う必要がある

Hasura メインページのテスト

Mockサーバーからテストデータ取得できるかのテストを行う
HasuraMain.test.tsxを作成

HasuraMain.test.tsx 基本形

/**
* @jest-environment jsdom
*/
import { render, screen, cleanup } from "@testing-library/react"
import '@testing-library/jest-dom/extend-expect'
import { setupServer } from 'msw/node'
import { getPage, initTestHelpers } from 'next-page-tester'
import { handlers } from "../mock/handlers"
import 'setimmediate'

initTestHelpers()

const server = setupServer(...handlers)

beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

describe('Hasura Fetch Test Case', () => {
    it ('Should render the list of users by useQuery', async () => {
       const { page } = await getPage({
           route: '/hasura-main',
       })
       render(page)
       expect(await screen.findByText('Hasura main page')).toBeInTheDocument() 
    })
})


HasuraMain.test.tsx モックサーバーからGetUsersでデータを取得してテストが通ることを確認する

describe('Hasura Fetch Test Case', () => {
    it ('Should render the list of users by useQuery', async () => {
       ..........................
       expect(await screen.findByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('Test user C')).toBeInTheDocument()
    })
})

レンダリングに時間がかからないテストに関してはawaitは省くことができる
Test user Aのawaitは初期画面レンダリング時にはまだサーバーにフェッチしている状態でデータがレンダリングされていない状況がありうるためawaitを入れている
※自分の環境ではエラーが出るためすべてawaitを記載
→ findByTextはawaitを使わなければ即刻データを取得してしまうからだった
→またgetByTextはawaitを使えない模様

Tips!

ApolloClientのCache部分まではテストできない


CRUDに関するテストの実装

HasuraCRUD.test.tsxを作成
インポート部分は他のテストと同じなので省略

HasuraCRUD.test.tsx

describe('Hasura CRUD Test Case', () => {
    it ('Should render the list of users by useQuery', async () => {
       const { page } = await getPage({
           route: '/hasura-crud',
       })
       render(page)
       expect(await screen.findByText('Hasura CRUD')).toBeInTheDocument()
       expect(await screen.findByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('2021-01-01T13:47:23.308833+00:00')).toBeInTheDocument()
       expect(screen.getByTestId('edit-b39ec6cc-9d85-458d-aa0e-00000000000a')).toBeTruthy()
       expect(screen.getByTestId('delete-b39ec6cc-9d85-458d-aa0e-00000000000a')).toBeTruthy()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('2021-02-02T13:47:23.308833+00:00')).toBeInTheDocument()
       expect(screen.getByTestId('edit-b39ec6cc-9d85-458d-aa0e-00000000000b')).toBeTruthy()
       expect(screen.getByTestId('delete-b39ec6cc-9d85-458d-aa0e-00000000000b')).toBeTruthy()
       expect(screen.getByText('Test user C')).toBeInTheDocument()
       expect(screen.getByText('2021-03-03T13:47:23.308833+00:00')).toBeInTheDocument()
       expect(screen.getByTestId('edit-b39ec6cc-9d85-458d-aa0e-00000000000c')).toBeTruthy()
       expect(screen.getByTestId('delete-b39ec6cc-9d85-458d-aa0e-00000000000c')).toBeTruthy()
    })
})

created_atのデータなどの動的データstateのデータはgetByTextで取得する
ボタンなどのコンポーネントはdata-testidが見つかるかどうかで判断する
判定はtoBeTruthyとなる


SSG + ISRのテスト

HasuraSSG.test.tsx

describe('Hasura SSG Test Case', () => {
    it ('Should render the list of users pre-fetched by getStaticProps', async () => {
       const { page } = await getPage({
           route: '/hasura-ssg',
       })
       render(page)
       expect(await screen.findByText('SSG+ISR')).toBeInTheDocument()
       expect(screen.getByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('Test user C')).toBeInTheDocument()
    })
})

事前レンダリングされているはずなので、testAなどはawaitが不要なのでgetByTextで取得する

SSG 個別ページusers/[id].tsxのテスト

HasuraSSGDetail.test.tsxを作成

describe('UserDetail Test Case', () => {
    it ('Should render the user detail pre-fetched by getStaticProps', async () => {
       const { page } = await getPage({
           route: '/users/b39ec6cc-9d85-458d-aa0e-00000000000a', // Test user A
       })
       render(page)
       expect(await screen.findByText('User detail')).toBeInTheDocument()
       expect(screen.getByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('2021-01-01T13:47:23.308833+00:00')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('back-to-main'))
       expect(await screen.findByText('SSG+ISR')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('link-b39ec6cc-9d85-458d-aa0e-00000000000b')) // Test user B
       expect(await screen.findByText('User detail')).toBeInTheDocument()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('2021-02-02T13:47:23.308833+00:00')).toBeInTheDocument()
    })
})

個別ページはrouteがモック化したデータのidを使う
route: '/users/b39ec6cc-9d85-458d-aa0e-00000000000a',
テストユーザーAの内容を確認した後に一度ユーザー一覧画面に戻り、テストユーザーBの詳細画面に移動できることを確認している



Hasuraのセキュリティ

Hasura Endpoint にプロテクションをかける

Hausuraのエンドポイントはデフォルトでは誰でもアクセスできる状態になっている

プロテクション対処法1 JWTトークンを利用する

実用上はこちらを利用する
自身で調べる必要がある

Firebase の Authentification を使用する方法がある
email を利用したアカウント作成を行うことができる

プロテクション対処法2 Hasura admin secretを利用する

今回はこちらを利用する

https://cloud.hasura.io/projectにアクセスして自身のプロジェクトの設定画面を開く(歯車マーク)
Env vars > new Env var > HASURA_GRAPHQL_ADMIN_SECRET
valueに複雑なパスワードキーを入力してAdd
コンソールに移動するとx-hasura-admin-secretが追加されていることがわかる
x-hasura-admin-secretがない状態ではデータベースにアクセスできない

Error: x-hasura-admin-secret/x-hasura-access-key required, but not found

環境変数の作成

アクセスできないと困るので先ほどvalueに入れたパスワードの値を環境変数に格納する
.env.local ファイルの作成して""の部分にパスワードを格納する

NEXT_PUBLIC_HASURA_KEY=""

apolloClientのヘッダーにx-hasura-admin-secretを追加する

apolloClient.ts

link: new HttpLink({
        uri: ........................................
        headers: {
          'x-hasura-admin-secret': process.env.NEXT_PUBLIC_HASURA_KEY,
        }
      }),

※このファイルのexport const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'は使用しないため削除またはコメントアウトしてよい

URIも環境変数化する .env.local に追記

NEXT_PUBLIC_HASURA_URL="https://xxxxxxxxx.hasura.app/v1/graphql"

apolloClient.ts のURLを環境変数から読み込む

link: new HttpLink({
        uri: process.env.NEXT_PUBLIC_HASURA_URL
        headers: {
          'x-hasura-admin-secret': process.env.NEXT_PUBLIC_HASURA_KEY,
        }
      }),

テストモックが環境変数を読み込めるようにする実装

テストモックはデフォルトでローカルの環境変数(.env.local)を読み込まないので、テスト用の環境変数をあらたに用意してあげる必要がある
.env.test.local を作成して、.env.localと同じ内容を記載する

.env.test.local

NEXT_PUBLIC_HASURA_KEY=""
NEXT_PUBLIC_HASURA_URL="https://xxxxxxxxx.hasura.app/v1/graphql"

yarn test を行ってテストが通ることを確認できればOK

トラブルシュート

.env.test.localの環境変数が上手く読み込まれない場合、

対処1 Home.test.tsx以外の全てのテストファイルの import 直下に下記環境変数を定義してからyarn testを実行

process.env.NEXT_PUBLIC_HASURA_URL = 'https://xxx.hasura.app/v1/graphql'


対処2 環境変数を書くテストケースで読み込む

以下の記事に解決策の兆しは書いてあったものその通りの方法を実施した場合ではエラーが出たため自環境では各テストケースで環境変数を読み込む実装とした
Jestを用いたNext.jsのテスト内で環境変数を利用する - Breath Note

プロジェクト直下に
test/setupEnv.ts を作成し、以下を実装

import { loadEnvConfig } from '@next/env'

export const setupEnv = async (): Promise<void> => {
  loadEnvConfig(process.env.PWD || process.cwd())
}

各テストのテストケースで上記のsetupEnvを実行する
XXXXX.test.tsx

// ..........................
import { setupEnv } from "../test/setupEnv" 

//.......................

describe('.................................', () => {
    it ('.................................', async () => {
        await setupEnv()
        // ......................................

Vercelへデプロイ作業

前記事参照
Next.js基本編学習メモ - 古生代紀のブログ

1 リポジトリを作成してgit hubにコミット・プッシュする
2 VercelのダッシュボードのNewProjectで先ほどプッシュしたリポジトリをVercelにデプロイ
build and output settingsの設定でデプロイ前テスト自動実行をさせることができる
すべてのテストが通らないとデプロイされない
buld command の Overrideをオンにして、テキストボックスにテストとビルドを実行させるコマンドを記載する

yarn test && yarn build

Environmental Variablesに .env.local に記載した環境変数の値を入れる

3. デプロイをクリック
4 デプロイが成功して動作確認で問題なければ成功


END.

Next.jsのテスト実行時に.env.test.localの環境変数が読み込まれない時の対処

はじめに

Next.js開発においてAPIエンドポイントのURLを環境変数化した場合にアプリ自体は動くがテストが通らなくなる事案が発生
調べによるとJESTは環境変数を.env.test.localに記載してもうまく読み込んでくれない場合がある模様

環境

OS: Windows 10
node -v: v14.18.0
yarn -v: 1.22.17

package.json

{
  "name": "hoge-project",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest --env=jsdom --verbose",
    "gen-types": "graphql-codegen --config codegen.yml"
  },
  "dependencies": {
    "@apollo/client": "^3.4.17",
    "@apollo/react-hooks": "^4.0.0",
    "@heroicons/react": "^1.0.5",
    "autoprefixer": "^10.4.0",
    "cross-fetch": "^3.1.4",
    "graphql": "^16.0.1",
    "next": "11.1.2",
    "postcss": "^8.3.11",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "setimmediate": "^1.0.5",
    "tailwindcss": "^2.2.19"
  },
  "devDependencies": {
    "@babel/core": "^7.16.0",
    "@graphql-codegen/cli": "2.3.0",
    "@graphql-codegen/typescript": "^2.4.1",
    "@graphql-codegen/typescript-operations": "2.2.1",
    "@graphql-codegen/typescript-react-apollo": "3.2.2",
    "@testing-library/dom": "^8.11.1",
    "@testing-library/jest-dom": "^5.15.0",
    "@testing-library/react": "^12.1.2",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.0.3",
    "@types/node": "^16.11.8",
    "@types/react": "^17.0.35",
    "babel-jest": "^27.3.1",
    "eslint": "7",
    "eslint-config-next": "12.0.4",
    "jest": "^27.3.1",
    "jest-css-modules": "^2.1.0",
    "msw": "^0.35.0",
    "next-page-tester": "^0.30.0",
    "typescript": "^4.5.2"
  },
  "jest": {
    "testPathIgnorePatterns": [
      "<rootDir>/.next/",
      "<rootDir>/node_modules/"
    ],
    "moduleNameMapper": {
      "\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
    }
  }
}

対処法

対処1 Home.test.tsx以外の全てのテストファイルの import 直下に下記環境変数を定義してからyarn testを実行
process.env.NEXT_PUBLIC_HASURA_URL = 'https://xxx.hasura.app/v1/graphql'

こちらはテストをGitHubにアップロードするのをためらってしまうため実用的ではない

対処2 環境変数を各テストケースで毎回読み込む

以下の記事に解決策は書いてあったものその通りの方法を実施した場合ではエラーが出たため自環境では各テストケースで環境変数を読み込む実装とした
Jestを用いたNext.jsのテスト内で環境変数を利用する - Breath Note

プロジェクト直下に
test/setupEnv.ts を作成し、以下を実装

import { loadEnvConfig } from '@next/env'

export const setupEnv = async (): Promise<void> => {
  loadEnvConfig(process.env.PWD || process.cwd())
}

各テストのテストケースで上記のsetupEnvを実行する
XXXXX.test.tsx

// ..........................
import { setupEnv } from "../test/setupEnv" 

//.......................

describe('.................................', () => {
    it ('.................................', async () => {
        await setupEnv()
        // ......................................

こちらは各テストケースに記載する手間はあるが、外部ファイルから環境変数を読み込む形でテストを利用できる

対処3 環境変数をテスト準備時に読み込む

対処2ではhandlersがsetupEnvより前に記述されている場合に環境変数を読み込んでくれないという事案が発生する
解決方法としては await setupEnv() の処理をテスト準備処理をしているコード上部に持ってきてawaitを取り除いてsetupEnv() を行う
こうすることでsetupEnv()を各テストケースに記載する手間も省けるのでこの形が最も良い


XXXXX.test.tsx

// ..........................
import { setupEnv } from "../test/setupEnv" 

setupEnv()

//.......................テスト準備の処理

// 本チャンテストにsetupEnvを記述する必要なし
describe('.................................', () => {
    it ('.................................', async () => {
        // ......................................