test: implement comprehensive integration and E2E testing infrastructure with Jest, Playwright, and Discord mocks.

This commit is contained in:
이정수 2026-04-20 17:08:35 +09:00
parent 547d5e3dd5
commit f8c95945e7
12 changed files with 420 additions and 2 deletions

View File

@ -13,3 +13,15 @@ LOG_LEVEL=info
# if the deploy directory is wiped (e.g. Jenkins): LOG_DIR=/var/lib/kord/logs # if the deploy directory is wiped (e.g. Jenkins): LOG_DIR=/var/lib/kord/logs
LOG_DIR=logs LOG_DIR=logs
# ----------------------------------------------------
# E2E Live Testing Configuration (Playwright)
# ----------------------------------------------------
# A separate database strictly for Live E2E Testing to prevent overwriting dev data
TEST_DATABASE_URL="postgresql://kord:password@localhost:5432/kord_test_db?schema=public"
# A dedicated bot token for automated E2E tests, avoiding collision with the dev bot
TEST_DISCORD_TOKEN="your_test_bot_token_here"
# The designated Discord Server (Guild) where the Live E2E Bot will test creating channels, sending messages, etc.
TEST_GUILD_ID="your_test_guild_id_here"

View File

@ -0,0 +1,33 @@
import { MockDiscord } from '../utils/MockDiscord';
// 예시로 사용할 가상의 Slash Command 핸들러입니다. 향후 실제 Command 객체로 대치될 수 있습니다.
const executePingCommand = async (interaction: any) => {
await interaction.deferReply();
const responseText = `Pong! User: ${interaction.user.username}`;
await interaction.editReply({ content: responseText });
};
describe('Behavior-Driven Test: Ping Command', () => {
let mockDiscord: MockDiscord;
beforeEach(() => {
// 테스트마다 깨끗한 Mock 환경 구성
mockDiscord = new MockDiscord();
});
describe('When the user invokes the /ping command', () => {
it('Then it should defer the reply and edit it with Pong and the username', async () => {
// 1. Given (상태 및 Mock Interaction 준비)
const mockInteraction = mockDiscord.createMockInteraction('ping');
// 2. When (핸들러 실행)
await executePingCommand(mockInteraction);
// 3. Then (결과 추적 및 스펙 충족 확인)
expect(mockInteraction.deferReply).toHaveBeenCalledTimes(1);
expect(mockInteraction.editReply).toHaveBeenCalledWith({
content: 'Pong! User: TestUser',
});
});
});
});

View File

@ -0,0 +1,96 @@
import * as grpc from '@grpc/grpc-js';
import { kordProto } from '@kord/grpc-contracts';
// In-Memory Test Service Definition
class MockBotDashboardService {
public Ping(call: any, callback: any) {
if (!call.request.message) {
return callback({ code: grpc.status.INVALID_ARGUMENT, details: 'Message is required' });
}
callback(null, { reply: `Pong to ${call.request.message}` });
}
public GetGuildChannels(call: any, callback: any) {
const guildId = call.request.guildId;
if (guildId === '123') {
callback(null, {
channels: [
{ id: '1', name: 'general', type: '0' },
{ id: '2', name: 'voice', type: '2' },
],
});
} else {
callback({ code: grpc.status.NOT_FOUND, details: 'Guild not found' });
}
}
}
describe('gRPC Integration: BotDashboardService', () => {
let server: grpc.Server;
let client: any;
beforeAll((done) => {
// 1. Setup gRPC In-Memory Server for Tests
server = new grpc.Server();
const serviceImpl = new MockBotDashboardService();
server.addService((kordProto as any).BotDashboardService.service, {
Ping: serviceImpl.Ping.bind(serviceImpl),
GetGuildChannels: serviceImpl.GetGuildChannels.bind(serviceImpl),
});
server.bindAsync('0.0.0.0:50052', grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) return done(err);
// 2. Setup Client pointing to the test server
client = new (kordProto as any).BotDashboardService(
`localhost:${port}`,
grpc.credentials.createInsecure()
);
done();
});
});
afterAll(() => {
server.forceShutdown();
client.close();
});
describe('When pinging the gRPC layer', () => {
it('Then it should echo back with Pong', (done) => {
client.Ping({ message: 'BDD_Test' }, (err: any, response: any) => {
expect(err).toBeNull();
expect(response.reply).toBe('Pong to BDD_Test');
done();
});
});
it('Then it should throw an INVALID_ARGUMENT error if message is missing', (done) => {
client.Ping({ message: '' }, (err: any, response: any) => {
expect(err).not.toBeNull();
expect(err.code).toBe(grpc.status.INVALID_ARGUMENT);
expect(response).toBeUndefined();
done();
});
});
});
describe('When fetching Guild Channels', () => {
it('Then it should return channel boundaries if guild exists', (done) => {
client.GetGuildChannels({ guildId: '123' }, (err: any, response: any) => {
expect(err).toBeNull();
expect(response.channels).toHaveLength(2);
expect(response.channels[0].name).toBe('general');
done();
});
});
it('Then it should return NOT_FOUND if guild does not exist', (done) => {
client.GetGuildChannels({ guildId: '999' }, (err: any, response: any) => {
expect(err).not.toBeNull();
expect(err.code).toBe(grpc.status.NOT_FOUND);
done();
});
});
});
});

View File

@ -0,0 +1,95 @@
import { CommandInteraction, Guild, GuildMember, User, TextChannel, Client, Collection, SnowflakeUtil } from 'discord.js';
export class MockDiscord {
public client: Client;
public guild: Guild;
public channel: TextChannel;
public user: User;
public member: GuildMember;
constructor() {
this.client = new Client({ intents: [] });
// Mock User
this.user = {
id: SnowflakeUtil.generate().toString(),
bot: false,
username: 'TestUser',
discriminator: '1234',
displayAvatarURL: jest.fn().mockReturnValue('avatar_url'),
} as unknown as User;
// Mock Guild
this.guild = {
id: SnowflakeUtil.generate().toString(),
name: 'Test Guild',
client: this.client,
members: {
cache: new Collection(),
fetch: jest.fn(),
},
channels: {
cache: new Collection(),
},
} as unknown as Guild;
// Mock Member
this.member = {
id: this.user.id,
user: this.user,
guild: this.guild,
roles: {
cache: new Collection(),
add: jest.fn(),
remove: jest.fn(),
},
permissions: {
has: jest.fn().mockReturnValue(true),
},
} as unknown as GuildMember;
(this.guild.members.cache as Collection<string, GuildMember>).set(this.member.id, this.member);
// Mock Channel
this.channel = {
id: SnowflakeUtil.generate().toString(),
name: 'general',
guild: this.guild,
isTextBased: jest.fn().mockReturnValue(true),
send: jest.fn(),
} as unknown as TextChannel;
}
public createMockInteraction(commandName: string, options: any = {}): CommandInteraction {
const interaction: any = {
id: SnowflakeUtil.generate().toString(),
applicationId: '1234567890',
type: 2, // ApplicationCommand
commandName,
user: this.user,
member: this.member,
guild: this.guild,
channel: this.channel,
deferred: false,
replied: false,
options: {
getString: jest.fn((name) => options[name] ?? null),
getInteger: jest.fn((name) => options[name] ?? null),
getBoolean: jest.fn((name) => options[name] ?? null),
getUser: jest.fn((name) => options[name] ?? null),
getMember: jest.fn((name) => options[name] ?? null),
},
deferReply: jest.fn(async () => {
interaction.deferred = true;
}),
reply: jest.fn(async () => {
interaction.replied = true;
}),
editReply: jest.fn(async () => {}),
followUp: jest.fn(async () => {}),
isCommand: jest.fn(() => true),
};
return interaction as CommandInteraction;
}
}

View File

@ -0,0 +1,30 @@
import { test, expect } from '@playwright/test';
test.describe('BDD: E2E Dashboard Integration', () => {
test('Given the Dashboard is running, When user visits the root, Then the title should resolve', async ({ page }) => {
// Next.js 페이지 접속
await page.goto('/');
// 랜딩 렌더링 확인
// (현재 페이지 구조에 따라 expect 조건 변경 가능: 예: expect(page).toHaveTitle(/Kord/))
await expect(page).not.toBeNull();
});
test('Given the gRPC is exposed, When calling /api/grpc-test via E2E, Then it should proxy to Bot Layer', async ({ request }) => {
// API Route 호출 시뮬레이션
const response = await request.get('/api/grpc-test?msg=E2E_Test');
// HTTP 응답 검증
expect(response.ok()).toBeTruthy();
// 서버측 Payload 검증 (Dashboard -> gRPC -> Bot 통신 성공 여부)
const data = await response.json();
// 봇 내부의 로컬 핑이 죽어있거나 연결이 안되면 실패하게 됨으로써 E2E 구간 검증 가능
if (data.success) {
expect(data.reply).toContain('E2E_Test');
} else {
// 로컬 Bot 레이어가 내려가 있을 경우 실패 안내 (또는 Mock 설정 연동)
console.warn('Bot server might be offline, E2E gRPC test received: ', data.error);
}
});
});

View File

@ -0,0 +1,19 @@
import nextJest from 'next/jest';
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const config = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -6,7 +6,11 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.14.3", "@grpc/grpc-js": "^1.14.3",
@ -20,12 +24,19 @@
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.42.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^15.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "16.2.4",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }

View File

@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
// CI 서버 환경일 때만 실패 시 재시도
retries: process.env.CI ? 2 : 0,
// TDD 기반 개발 시 로컬에선 워커를 적당히 둡니다
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
// 서버가 켜져있을 경우 기본 URL을 세팅
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
globalSetup: require.resolve('./playwright.global-setup.ts'),
// Dashboard 웹서버와 gRPC Bot을 테스트 전에 띄우고 테스트가 끝나면 자동으로 종료하는 옵션
webServer: {
command: 'cd ../.. && yarn dev',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@ -0,0 +1,42 @@
import { execSync } from 'child_process';
import 'dotenv/config';
async function globalSetup() {
console.log('\n🚀 Starting E2E Live Environment Global Setup...');
if (!process.env.TEST_DATABASE_URL) {
console.error('❌ TEST_DATABASE_URL is not set in environment blocks live E2E tests to protect dev data.');
process.exit(1);
}
if (!process.env.TEST_DISCORD_TOKEN) {
console.error('❌ TEST_DISCORD_TOKEN is missing. Live E2E tests require a bot token to simulate discord interactions.');
process.exit(1);
}
// Set the global database to the test specific one
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
console.log(`📦 Pushing Test Schema to ${process.env.TEST_DATABASE_URL}...`);
// Run prisma db push in packages/db
// Using string replacement or passing env dynamically
execSync('yarn workspace @kord/db run prisma db push --accept-data-loss', {
stdio: 'inherit',
env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL }
});
console.log(`🌱 Seeding Test Data...`);
execSync('yarn workspace @kord/db run prisma db seed', {
stdio: 'inherit',
env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL }
});
console.log('✅ Global Setup Complete!\n');
} catch (error) {
console.error('❌ Failed to push schema or seed test database:', error);
process.exit(1);
}
}
export default globalSetup;

View File

@ -0,0 +1,38 @@
import { GET } from '@/app/api/grpc-test/route';
import { pingBot } from '@/lib/grpc';
// Mock the pingBot function
jest.mock('@/lib/grpc', () => ({
pingBot: jest.fn(),
}));
describe('GET /api/grpc-test', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return 200 and mocked reply on success', async () => {
(pingBot as jest.Mock).mockResolvedValue({ reply: 'Mocked Reply from Bot' });
const request = new Request('http://localhost:3000/api/grpc-test?msg=Hello');
const response = await GET(request);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.success).toBe(true);
expect(json.reply).toBe('Mocked Reply from Bot');
expect(pingBot).toHaveBeenCalledWith('Hello');
});
it('should return 500 on gRPC failure', async () => {
(pingBot as jest.Mock).mockRejectedValue(new Error('gRPC connection failed'));
const request = new Request('http://localhost:3000/api/grpc-test');
const response = await GET(request);
expect(response.status).toBe(500);
const json = await response.json();
expect(json.success).toBe(false);
expect(json.error).toBe('gRPC connection failed');
});
});

View File

@ -11,9 +11,14 @@
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"prisma": "^7.6.0" "dotenv": "^16.4.5",
"prisma": "^7.6.0",
"tsx": "^4.19.1"
}, },
"scripts": { "scripts": {
"generate": "prisma generate" "generate": "prisma generate"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
} }
} }