🔈

SlackにPlaywrightのテスト開始と結果を通知する仕組みづくり

2024/11/23に公開

開発の背景

Playwrightの特徴

Playwrightは、WEBアプリに対して、複数のレンダリングエンジンでE2Eテストを実行できるツールです。
Playwrightは、用意されたテストを実行すると、ブラウザが起動しユーザーがWEBサイトで行う操作と同等の操作を行います。
ただPlaywrightによるテスト実行は、ブラウザを操作するため、ユニットテストと比較して実行時間が長くなる傾向があります。

チャットサービスへの通知仕組み構築までの動機

プロダクトのリリース時には、すべてのテストが完了し、発見された不具合がすべて解消あるいは解消見込みである必要があります。
そのためPlaywrightでのテストが、テスト実行の完了時に、プロジェクト関係者が早急に結果を確認できる仕組みが重要となります。
そこで本記事では、Playwrightのテスト結果を迅速に伝える手法として、テスト結果をSlackへ通知する仕組みの構築について共有します。

Playwright関連で現在利用できる仕組み

Playwright標準のレポート機能

Playwrightのレポート機能には、以下の出力形式を選択でき、状況に応じてテスト結果を出力できます。

Playwrightでは、プロジェクト関係者誰もが確認しやすいフォーマットとして、HTML形式としてテスト実行結果を出力できるため、非常に利便性が高いです。

OSSのPlaywright-Slackライブラリ

公式には、Slackへの通知手段が用意されていません。
OSSのライブラリでは、テスト結果をSlackに通知するライブラリがあります。
しかし既存のライブラリは、テスト実施前に通知する手段が用意されていません。
そこで今回、テスト実施前にもSlackへ通知する手段を考えました。

Goでjsonファイルの解析からSlackへの通知までを開発

Sackへの通知に必要な情報を整理する

最初の検討事項は、Playwright実行前と実行後にSlackで表示される情報です。
Playwrightの実行開始時には、以下の情報を通知することで、テスト予定の内容がプロジェクト関係者に伝わるよう用意します。

  • Playwrightの設定ファイル
    • 目的:テストを実行しているプロダクトを確認する
    • 手段:プロダクト単位で設定ファイルを複数用意する
  • Playwrightのテストタグ
    • 目的:絞り込まれたテストシナリオを確認する
    • 手段:テストタグを指定する
  • 付加情報
    • 目的:プロジェクト関係者にとって必要な情報を伝える
    • 手段:プロダクト独自の情報を追記する

Playwrightの実行完了時には、以下の情報を通知することで、テストを正常に終えられたか、またテスト実行結果の詳細な情報をHTMLレポートとして伝わるよう用意します。

  • 全テストシナリオの成否
    • 目的:全テストシナリオのテスト結果を視覚的に伝える
    • 手段:結果を色(成功:緑、失敗:赤)で表示する
  • PlaywrightのHTMLレポート
    • 目的:詳細なテスト結果を伝える
    • 手段:HTMLレポートを添付する
  • テスト実行結果の分析結果
    • 目的:HTMLレポートを確認することなく、テスト実行結果を確認する
    • 手段:テスト結果を分析して整形表示する
  • 実行にかかった時間
    • 目的:将来的なテストシナリオのリファクタリングに繋げる
    • 手段:Playwrightの実行にかかった時間を記載する

Playwrightの実行からSlackへの通知までの流れを考える

以下の流れに対して、Slackへの通知の仕組みを考えます。

  1. Playwrightを実行する
  2. Playwrightのレポート機能で、jsonHTMLを出力する
  3. jsonファイルを解析する
    • ↑ 本記事で紹介する内容
  4. 解析結果からSlackへのテキストを構成する
    • ↑ 本記事で紹介する内容
  5. Slackに通知する

Playwrightのテスト実行結果を分析する

Playwrightのテスト実行分析は、レポート機能のjsonの結果を利用します。
分析結果は、jsonの中身を解析し結果を集計し、Slackのattachmentsの装飾や1次情報としてのテスト結果の通知に活用します。

Model

本項では、Playwrightで出力されたjsonファイルの中身を、生データとして保持する構造体を定義します。

spec.go
// Spec is a part of Playwright suite.
type Spec struct {
	Title string   `json:"title"`
	Ok    bool     `json:"ok"`
	Tags  []string `json:"tags"`
}
suite.go
// Suite is a part of Playwright summary.
type Suite struct {
	File   string  `json:"file"`
	Specs  []Spec  `json:"specs"`
	Suites []Suite `json:"suites"`
}
statistics.go
import (
	"fmt"
	"time"
)

// Statistics is a part of Playwright summary.
type Statistics struct {
	StartTime time.Time `json:"startTime"`
	Duration  float64   `json:"duration"`
	Passed    int64     `json:"expected"`
	Skipped   int64     `json:"skipped"`
	Failed    int64     `json:"unexpected"`
	Flaky     int64     `json:"flaky"`
}

func (m *Statistics) All() int64 {
	return m.Passed + m.Failed + m.Skipped + m.Flaky
}

func (m *Statistics) GetColorCode() string {
	if m.Failed > 0 {
		return "#e01e5a"
	} else {
		return "#36a64f"
	}
}

func (m *Statistics) GetDuration() (time.Duration, error) {
	return time.ParseDuration(fmt.Sprintf("%fms", m.Duration))
}
summary.go
// Summary is a struct for Playwright summary.
type Summary struct {
	Suites []Suite    `json:"suites"`
	Stats  Statistics `json:"stats"`
}

Service

本項では、前項で定義した構造体に対して、jsonファイルを解析しデータを保持するサービスを定義します。

playwrightSummary.go
import (
	"encoding/json"
	"fmt"
	"os"
)

type PlaywrightSummary interface {
	Analyze(filePath string) (*model.Summary, error)
}

type jsonSummary struct {}

func (s *jsonSummary) Analyze(jsonFilePath string) (*model.Summary, error) {
	var summary model.Summary
	raw, err := os.ReadFile(jsonFilePath)
	if err != nil {
		return nil, fmt.Errorf("failed to open summary.json: %w", err)
	}
	err = json.Unmarshal(raw, &summary)
	if err != nil {
		return nil, fmt.Errorf("failed to unmarshal summary.json: %w", err)
	}
	return &summary, nil
}

func NewJsonSummary() PlaywrightSummary {
	return &jsonSummary{}
}

Test

本項では、前項までで用意したPlaywrightのjsonファイルを分析するサービスの利用例を紹介します。

playwrightSummary_test.go
import (
	"log"
	"testing"
)

var ps = NewJsonSummary()

func TestPlaywrightSummary_Analyze(t *testing.T) {
	summary, err := ps.Analyze("/tmp/summary.json")
	if err != nil {
		t.Error(err)
		return
	}
	log.Print(summary)
}

レポート結果をSlackに通知する

分析した結果から、SlackのBlock kitの仕様に従って、テキストを構成します。
テスト実行が完了した通知では、設定ファイルにHTMLのテスト結果のリンクを付与します。

Service

slackService.go
import (
	"context"
	"fmt"
	"github.com/slack-go/slack"
	"time"
)

type ChatService interface {
	NotifyBeginning(ctx context.Context, config string, grepKeyWord string, options map[string]string) error
	NotifyEnding(ctx context.Context, configFileName string, grepKeyWord string, options map[string]string, S3URL string, summary *model.Summary) error
}

type slackService struct {
	client    *slack.Client //https://api.slack.com/apps/A070VGADMU4/oauth?success=1
	channelId string
}

func (s *slackService) NotifyBeginning(ctx context.Context, config string, grepKeyword string, options map[string]string) error {
	var fields []slack.AttachmentField
	for k, v := range options {
		fields = append(fields, slack.AttachmentField{
			Title: k,
			Value: v,
			Short: true,
		})
	}
	_, _, err := s.client.PostMessageContext(
		ctx,
		s.channelId,
		slack.MsgOptionBlocks(
			&slack.HeaderBlock{
				Type: slack.MBTHeader,
				Text: &slack.TextBlockObject{
					Type: slack.PlainTextType,
					Text: ":man-running: Playwrightによるテストを開始します",
				},
			},
			&slack.RichTextBlock{
				Type: slack.MBTRichText,
				Elements: []slack.RichTextElement{
					&slack.RichTextSection{
						Type: slack.RTESection,
						Elements: []slack.RichTextSectionElement{
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: config,
							},
						},
					},
				},
			},
			&slack.RichTextBlock{
				Type: slack.MBTRichText,
				Elements: []slack.RichTextElement{
					&slack.RichTextSection{
						Type: slack.RTESection,
						Elements: []slack.RichTextSectionElement{
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: "テストタグ: ",
								Style: &slack.RichTextSectionTextStyle{
									Bold: true,
								},
							},
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: fmt.Sprintf("%s", grepKeyword),
							},
						},
					},
				},
			},
			slack.NewDividerBlock(),
		),
		slack.MsgOptionAttachments(
			slack.Attachment{
				Fields: fields,
				Footer: time.Now().Format("2006/01/02 15:04:05"),
			},
		),
	)
	if err != nil {
		return fmt.Errorf("failed to post a message to slack: %w", err)
	}
	return nil
}

func (s *slackService) NotifyEnding(ctx context.Context, configFileName string, grepKeyWord string, options map[string]string, S3URL string, summary *model.Summary) error {
	var fields []slack.AttachmentField
	for k, v := range options {
		fields = append(fields, slack.AttachmentField{
			Title: k,
			Value: v,
			Short: true,
		})
	}
	dur, _ := summary.Stats.GetDuration()
	_, _, err := s.client.PostMessageContext(
		ctx,
		s.channelId,
		slack.MsgOptionBlocks(
			&slack.HeaderBlock{
				Type: slack.MBTHeader,
				Text: &slack.TextBlockObject{
					Type: slack.PlainTextType,
					Text: ":checkered_flag: Playwrightによるテストが完了しました",
				},
			},
			&slack.RichTextBlock{
				Type: slack.MBTRichText,
				Elements: []slack.RichTextElement{
					&slack.RichTextSection{
						Type: slack.RTESection,
						Elements: []slack.RichTextSectionElement{
							&slack.RichTextSectionLinkElement{
								Type: slack.RTSELink,
								Text: configFileName,
								URL:  S3URL,
							},
						},
					},
				},
			},
			slack.NewDividerBlock(),
			&slack.RichTextBlock{
				Type: slack.MBTRichText,
				Elements: []slack.RichTextElement{
					&slack.RichTextSection{
						Type: slack.RTESection,
						Elements: []slack.RichTextSectionElement{
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: "テスト結果",
								Style: &slack.RichTextSectionTextStyle{
									Bold: true,
								},
							},
						},
					},
					s.statisticsFormat(0, "ALL", true),
					s.statisticsFormat(1, fmt.Sprintf("%d", summary.Stats.All()), false),
					s.statisticsFormat(0, "PASSED", true),
					s.statisticsFormat(1, fmt.Sprintf("%d", summary.Stats.Passed), false),
					s.statisticsFormat(0, "FAILED", true),
					s.statisticsFormat(1, fmt.Sprintf("%d", summary.Stats.Failed), false),
					s.statisticsFormat(0, "FLAKY", true),
					s.statisticsFormat(1, fmt.Sprintf("%d", summary.Stats.Flaky), false),
					s.statisticsFormat(0, "SKIPPED", true),
					s.statisticsFormat(1, fmt.Sprintf("%d", summary.Stats.Skipped), false),
					&slack.RichTextSection{
						Type: slack.RTESection,
						Elements: []slack.RichTextSectionElement{
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: "実行時間: ",
								Style: &slack.RichTextSectionTextStyle{
									Bold: true,
								},
							},
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: fmt.Sprintf("%s", dur),
							},
						},
					},
					&slack.RichTextSection{
						Type: slack.RTESection,
						Elements: []slack.RichTextSectionElement{
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: "テストタグ: ",
								Style: &slack.RichTextSectionTextStyle{
									Bold: true,
								},
							},
							&slack.RichTextSectionTextElement{
								Type: slack.RTSEText,
								Text: fmt.Sprintf("%s", grepKeyWord),
							},
						},
					},
				},
			},
			slack.NewDividerBlock(),
		),
		slack.MsgOptionAttachments(
			slack.Attachment{
				Color:  summary.Stats.GetColorCode(),
				Fields: fields,
				Footer: time.Now().Format("2006/01/02 15:04:05"),
			},
		),
	)
	if err != nil {
		return fmt.Errorf("failed to post a message to slack: %w", err)
	}
	return nil
}

func (s *slackService) statisticsFormat(indent int, text string, isBold bool) slack.RichTextElement {
	return &slack.RichTextList{
		Type:   slack.RTEList,
		Style:  slack.RTEListBullet,
		Indent: indent,
		Elements: []slack.RichTextElement{
			&slack.RichTextSection{
				Type: slack.RTESection,
				Elements: []slack.RichTextSectionElement{
					&slack.RichTextSectionTextElement{
						Type: slack.RTSEText,
						Text: text,
						Style: &slack.RichTextSectionTextStyle{
							Bold: isBold,
						},
					},
				},
			},
		},
	}
}

func NewSlackService(accessToken string, channelId string, isDebug bool) ChatService {
	return &slackService{
		client:    slack.New(accessToken, slack.OptionDebug(isDebug)),
		channelId: channelId,
	}
}

Test

本項では、前項までで用意したSlackへの通知サービスの利用例を紹介します。

chatService_test.go
import (
	"context"
	"testing"
)

var chatService = NewSlackService(
	"xoxb-9999999999-9999999999999-abcdefghijklmnopqrstuvwx",
	"#hogehoge",
	false)
var successMock = model.Summary{
	Stats: model.Statistics{
		Passed:   9,
		Failed:   0,
		Flaky:    1,
		Skipped:  1,
		Duration: 364213.944,
	},
}
var failedMock = model.Summary{
	Stats: model.Statistics{
		Passed:   8,
		Failed:   1,
		Flaky:    2,
		Skipped:  1,
		Duration: 364213.944,
	},
}

func TestChatService_NotifyBeginning(t *testing.T) {
	err := chatService.NotifyBeginning(
		context.Background(),
		"playwright.test.config.ts",
		"@TopPage",
		map[string]string{"hoge": "piyo"},
	)
	if err != nil {
		t.Errorf("%+v", err)
	}
	return
}

func TestChatService_NotifyEnding_Success(t *testing.T) {
	err := chatService.NotifyEnding(
		context.Background(),
		"playwright.test.config.ts",
		"@TopPage", map[string]string{"hoge": "piyo"},
		"https://playwright.dev/",
		&successMock,
	)
	if err != nil {
		t.Errorf("%+v", err)
	}
	return
}

func TestChatService_NotifyEnding_Failed(t *testing.T) {
	err := chatService.NotifyEnding(
		context.Background(),
		"playwright.test.config.ts",
		"@TopPage", map[string]string{"hoge": "piyo"},
		"https://playwright.dev/",
		&failedMock,
	)
	if err != nil {
		t.Errorf("%+v", err)
	}
	return
}

Slackへの通知例

Slackへの投稿例を以下に示します。

Playwrightの実行開始

Playwrightの実行開始前の通知例です。

Playwrightの実行開始

Playwrightの実行完了でテスト成功

Playwrightの実行後、すべてのテストが成功した通知例です。

Playwrightの実行完了でテスト成功

Playwrightの実行完了でテスト失敗

Playwrightの実行後、一部のテストが失敗した通知例です。

Playwrightの実行完了でテスト失敗

おわりに

Playwrightは、E2Eテストツールとして、プロダクトの仕様変更に強く、安定して実行できるツールです。
またPlaywrightは、他のサービスやツールとの連携が円滑にできるよう、拡張性高く設計されています。
本記事では、Playwrightの実行時や終了時の通知の仕組みを検討しました。
開発者が、Playwrightを積極的に利用できるよう、仕組みの改善を図っていきたいです。

Discussion