test: implement comprehensive integration and E2E testing infrastructure with Jest, Playwright, and Discord mocks.
This commit is contained in:
parent
547d5e3dd5
commit
f8c95945e7
12
.env.example
12
.env.example
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue