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
|
||||
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",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
|
|
@ -20,12 +24,19 @@
|
|||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.42.0",
|
||||
"@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/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"tailwindcss": "^4",
|
||||
"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": {
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^7.6.0"
|
||||
"dotenv": "^16.4.5",
|
||||
"prisma": "^7.6.0",
|
||||
"tsx": "^4.19.1"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "prisma generate"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue