7.1Fast Parallel Testing
Postgres를 사용하는 앱의 테스트 기법
이번 포스팅에선 앞선 포스팅들에서 만든것과 같은 앱을 테스트하는 과정에 대해 알아보자. 테스트는 여러 개의 테스트 파일로 우리가 이전에 만들어 쓰고 있는 socialnetworking이라는 데이터베이스에 잘 접근되는지를 알아보는 과정이다.
이를 위해서 이번 예시에선 테스트 러너 프로그램 중 하나인 Jest를 사용한다. Jest는 동시에 여러개의 파일을 테스트할 수 있는데, 이렇게 여러개의 파일을 동시에 테스트하면 필연적으로 하나의 데이터베이스에서 업데이트하는 내용들끼리 충돌(Conflict)이 발생할 수 있다.
테스트 진행하기
src/test/routes/users.test.js
를 만든다.
유저를 만드는 쿼리를 테스트하는 것으로, 처음에는 아무 유저가 없을 것으로 기대하고 유저를 생성한 이후에는 1명이 되는지 테스트하는 코드이다.
javascriptconst request = require("supertest"); const buildApp = require("../../app"); const UserRepo = require("../../repos/user-repo"); it("create a user", async () => { const startingCount = await UserRepo.count(); expect(startingCount).toEqual(0); await request(buildApp()) .post("/users") .send({ username: "testuser", bio: "test bio" }) .expect(200); const finishCount = await UserRepo.count(); expect(finishCount).toEqual(1); });
user-repo.js
에는 count
메소드를 추가한다.
javascriptstatic async count() { const { rows } = await pool.query("SELECT COUNT(*) FROM users;"); return parseInt(rows[0].count); }
package.json
의 스크립트에는 "test: jest"를 추가해서 jest로 테스트하게 한다.
이후 npm run test
를 실행하면, 아래와 같이 에러가 뜰 것이다.
bashTypeError: Cannot read properties of null (reading 'query') 15 | 16 | query(sql, params) { > 17 | return this._pool.query(sql, params); | ^ 18 | } 19 | } 20 |
이렇게 에러가 발생한 이유는, Pool
이 처음엔 null 값을 할당받고, 실제로 커넥션이 이루어지는 것은 앞서 작성한 index.js
파일에서만 connect되기 때문이다.
따라서, 테스트 파일에도 쿼리를 요청하기 전 커넥션을 만들어줘야한다. 아래와 같은 코드를 테스트 파일 상단에 추가하고, 다시 npm run test
를 실행해보자.
javascriptconst pool = require("../../pool"); beforeAll(() => { return pool.connect({ host: "localhost", port: 5432, database: "socialnetwork", user: "sungyupju", password: "", }); }); // ...it('create a user', ...)
그러면 아래와 같은 에러가 발생할 것이다.
bash16 | it("create a user", async () => { 17 | const startingCount = await UserRepo.count(); > 18 | expect(startingCount).toEqual(0); | ^ 19 | 20 | (await request(buildApp()).post("/users")) 21 | .setEncoding({ username: "testuser", bio: "test bio" }) Jest did not exit one second after the test run has completed.
이 에러는 2가지를 포함하는데,
- startingCount가 0으로 기대되었으나 1이 집계되었다.
- Jest는 테스트가 종료되고도 자동으로 종료되지 않았다.
Jest가 테스트가 종료되고도 종료되지 않은 것은 connection이 유지되고 있기 때문이다. 즉, 테스트를 시작하기 위해 커넥션을 만들었으면 테스트를 끝내고는 커넥션을 끊어야한다.
따라서 테스트 파일 상단에 아래와 같은 afterAll
코드를 추가한다.
javascriptafterAll(() => { return pool.close(); });
여러 데이터베이스 만들기
현재 방식으로 테스트를 하면 실제 배포 환경의 데이터에 영향을 준다. 따라서, 테스트 시에는 테스트용 데이터베이스에 연결하는 것이 좋다.
PGAdmin4에서 socialnetwork-test 데이터베이스를 생성한다. 또, 테스트 코드에서 pool.connect
에 database를 socialnetwork-test로 변경한다. 이후, 테이블을 추가하기 위해 마이그레이션 명령어인 DATABASE_URL=postgres://USERNAME@localhost:5432/socialnetwork-test npm run migrate up
를 실행한다.(USERNAME에 본인 username 입력)
이렇게 변경하고 npm run test
를 실행하면 아래와 같이 테스트 성공 메시지가 나온다.
bashPASS src/test/routes/users.test.js ✓ create a user (16 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.303 s, estimated 1 s Ran all test suites.
여러번 반복 가능한 테스트 만들기
테스트는 성공했지만, 한번 더 테스트를 실행하면 우리의 테스트 로직이 유저가 0명에서 1명으로 늘어나는지를 확인하는 것인데 1명이 이미 추가된 상태이기 때문에 테스트 실패가 나온다.
따라서, 로직 보완이 필요하다. 이전엔 finishCount
가 1인지 확인했는데, startingCount
에 비해 1이 증가하는지 확인하면 된다.
javascriptit("create a user", async () => { const startingCount = await UserRepo.count(); await request(buildApp()) .post("/users") .send({ username: "testuser", bio: "test bio" }) .expect(200); const finishCount = await UserRepo.count(); expect(finishCount - startingCount).toEqual(1); });
Parallel Test
본격적으로 여러 파일을 동시에 테스트하기 전에, package.json
파일의 스크립트를 수정하자.
javascript"scripts": { "migrate": "node-pg-migrate", "start": "nodemon index.js", "test": "jest --no-cache" },
그리고, users.js
파일을 복사헤 users-two.test.js
와 users-three.test.js
를 만든다.
이후, npm run test
를 실행하면 테스트들이 실패한다. 이는 테스트 파일들이 순차적으로 진행되지 않고 동시에 진행되기 때문이다. 동시에 파일들이 실행되며 count
값이 우리가 의도한것과 다르게 들어가고, 결과 또한 기대(expect)와 다르게 나와 실패하는 것이다.
이 문제를 해결하기 위해선 두 가지 방법이 있다:
- 각 테스트 파일마다 별도의 데이터베이스에서 테스트한다.
- 하나의 데이터베이스에서 모든 테스트 파일들이 실행되지만, 각 파일별로 별도의 schema(스키마)를 가지고 거기서 실행된다.
첫번째 방식은 데이터베이스를 불필요하게 많이 만들어야하고 파일이 많아질수록 관리가 어렵다는 단점이 있어, 일반적으로는 두번째 방식이 쓰인다.
스키마(Schema)란 데이터베이스 내의 폴더라고 볼 수 있는데, 테이블들이나 객체들을 묶어둘 수 있다. PGAdmin4에 들어가보면, 기본적으로 우리는 Public이라는 스키마를 쓰고 있다.
스키마의 좋은 점은, 테이블들의 복사본들을 모든 스키마에 가지고 있을 수 있다는 점이다.
스키마 만들고 접근하기
스키마를 만들기 위해선 PGAdmin4에서 socialnetwork-test 데이터베이스에서 쿼리 툴을 열고 아래 쿼리를 실행한다:
sqlCREATE SCHEMA test;
스키마에서 테이블에 접근하고 싶으면, 스키마명.테이블명
방식으로 접근한다.
sqlCREATE TABLE test.users ( id SERIAL PRIMARY KEY, username VARCHAR )
public 스키마는 기본값이기 때문에 별도로 스키마명을 적어주지 않아도 public.테이블명
처럼 작동한다. 하지만, 이 기본값은 바꿀 수 있다.
Postgres는 테이블명을 쿼리로 받으면 어떤 스키마에 접근할지 찾는다. 이 때, Postgres가 찾는 곳은 search_path
이다.
sqlSHOW search_path;
결과값은 아래와 같다.
search_path | |
---|---|
1 | "$user", public |
여기서 $user
는 우리가 앞서 pool.connect()
안에 넣은 user
인데, 위의 결과값은 Postgres에 로그인한 유저 값의 유저가 요청하는 유저와 같다면, public
스키마를 기본으로 참조하라는 의미이다.
기본값을 바꾸기 위해선 아래와 같은 쿼리를 실행하면 된다.
sqlSET search_path TO test, public;
원상복귀하려면 아래의 쿼리를 실행한다.
sqlSET search_path TO "$user", public;
스키마로 테스트 파일 환경 분리하기
새로 스키마를 만들었지만, 현재 user-repo.js
에서 count
메소드는 users
테이블을 참조하기 때문에 다 public.users
테이블에서 데이터를 세고 있다. 즉, 스키마를 분리했어도 모두 같은 public
스키마를 여전히 사용하고 있는 것이다.
sqlstatic async count() { const { rows } = await pool.query("SELECT COUNT(*) FROM users;"); return parseInt(rows[0].count); }
각 테스트 파일별로 다른 스키마를 쓰게 하는 방법은 아래와 같다:
- PGAdmin4에 접속한다.
- 랜덤한 문자열을 생성하고, 그 문자열로 된 user(role)와 스키마를 생성한다.
- 테스트 파일에는 그 랜덤 문자열로 된 스키마에 연결하도록 코드를 작성한다.
아래는 그렇게 작성한 users.test.js
파일이다.
javascriptconst request = require("supertest"); const buildApp = require("../../app"); const UserRepo = require("../../repos/user-repo"); const pool = require("../../pool"); const { randomBytes } = require("crypto"); const { default: migrate } = require("node-pg-migrate"); const format = require("pg-format"); beforeAll(async () => { // Randomly generating a role name to connect to PG // 참고로, Postgres에서는 role이 반드시 문자로 시작해야 한다. // 숫자로 시작하는 문자열을 방지하기 위해 "a"를 붙여준다. const roleName = "a" + randomBytes(4).toString("hex"); // Connect to PG as usual await pool.connect({ host: "localhost", port: 5432, database: "socialnetwork-test", user: "sungyupju", password: "", }); // Create a new role // 일반적으로, 아래와 같이 ${}식으로 identifier를 쓰는 것은 // SQL Injection 공격에 취약해지므로 지양하는 패턴이지만, 테스트 환경에서만 쓸 것이기에 사용한다. // (그래도 이 패턴을 수정하고 싶을 때 쓸 수 있는 방법에 대해서는 바로 아래서 다룬다) await pool.query(` CREATE ROLE ${roleName} WITH LOGIN PASSWORD '${roleName}'; `); // Create a schema with the same name await pool.query(` CREATE SCHEMA ${roleName} AUTHORIZATION ${roleName}; `); // Disconnect entirely from PG await pool.close(); // Run our migrations in the new schema await migrate({ schema: roleName, direction: "up", log: () => {}, noLock: true, dir: "migrations", databaseUrl: { host: "localhost", port: 5432, database: "socialnetwork-test", user: roleName, password: roleName, }, }); // Connect to PG as the newly created role await pool.connect({ host: "localhost", port: 5432, database: "socialnetwork-test", user: roleName, password: roleName, }); }); afterAll(() => { return pool.close(); }); it("create a user", async () => { const startingCount = await UserRepo.count(); await request(buildApp()) .post("/users") .send({ username: "testuser", bio: "test bio" }) .expect(200); const finishCount = await UserRepo.count(); expect(finishCount - startingCount).toEqual(1); });
Identifier 피하기
위의 테스트 파일에는 Identifier가 들어있는데, 테스트 환경에서 쓰므로 SQL Injection이 있거나 하진 않겠지만 그래도 이렇게 코드를 쓰는 것 자체를 지양하고 싶을 수 있다.
javascript// Create a new role await pool.query(` CREATE ROLE ${roleName} WITH LOGIN PASSWORD '${roleName}'; `); // Create a schema with the same name await pool.query(` CREATE SCHEMA ${roleName} AUTHORIZATION ${roleName}; `);
이전에 배운 패턴을 기억하신다면, 이렇게 우회하는 것을 제안할 수도 있다:
javascriptawait pool.query(` CREATE ROLE $1 WITH LOGIN PASSWORD $2; `, [roleName, roleName]) /// ... 마찬가지
하지만 우리가 쓰고 있는 node-postgres
라이브러리에서는 이런 식으로 role 이름, schema 이름 등의 Identifier에 $1, $2 등을 쓰는 문법을 지원하지 않는다.
pg-format
라이브러리의 format
을 쓰면 이 문제를 우회할 수 있다:
javascriptconst format = require("pg-format"); beforeAll(async () => { // ... // Create a new role await pool.query( format( ` CREATE ROLE %I WITH LOGIN PASSWORD %L; `, roleName, roleName) ); // Create a schema with the same name await pool.query( format( ` CREATE SCHEMA %I AUTHORIZATION %I; `, roleName, roleName) ); // ... } )
테스트 헬퍼 파일(스키마 만들고 clean-up 하기)
users.test.js
파일로 테스트를 진행하면, 매번 새로운 스키마가 생성된다. 테스트가 끝나면 이를 clean-up 하는 것이 좋은데, 이를 위해 테스트 헬퍼 파일을 만든다.
아래는 /src/test/context.js
파일이다. class 문법을 통해 Context를 선언하고 이 안에 users.test.js
에서 사용한 스키마 만들기를 build
메소드에 넣고, 클린업 로직을 close
메소드에 추가해 테스트를 반복적으로 진행했을 때 스키마와 role이 계속 늘어나는 것을 방지할 수 있다.
javascriptconst { randomBytes } = require("crypto"); const format = require("pg-format"); const pool = require("../pool"); const { default: migrate } = require("node-pg-migrate"); const DEFAULT_OPTS = { host: "localhost", port: 5432, database: "socialnetwork-test", user: "sungyupju", password: "", }; class Context { static async build() { // Randomly generating a role name to connect to PG // 참고로, Postgres에서는 role이 반드시 문자로 시작해야 한다. // 숫자로 시작하는 문자열을 방지하기 위해 "a"를 붙여준다. const roleName = "a" + randomBytes(4).toString("hex"); // Connect to PG as usual await pool.connect(DEFAULT_OPTS); // Create a new role // 일반적으로, 아래와 같이 template literal을 쓰면 SQL Injection 공격이 있을 수 있지만 // 테스트 환경에서만 쓸 것이기에 사용한다. // 이 패턴은 이후 수정할 것이다. await pool.query( format( ` CREATE ROLE %I WITH LOGIN PASSWORD %L; `, roleName, roleName ) ); // Create a schema with the same name await pool.query( format( ` CREATE SCHEMA %I AUTHORIZATION %I; `, roleName, roleName ) ); // Disconnect entirely from PG await pool.close(); // Run our migrations in the new schema await migrate({ schema: roleName, direction: "up", log: () => {}, noLock: true, dir: "migrations", databaseUrl: { ...DEFAULT_OPTS, user: roleName, password: roleName, }, }); // Connect to PG as the newly created role await pool.connect({ ...DEFAULT_OPTS, user: roleName, password: roleName, }); return new Context(roleName); } constructor(roleName) { this.roleName = roleName; } async close() { // Disconnect from PG await pool.close(); // Reconnect as the root user await pool.connect(DEFAULT_OPTS); // Delete the role and schema we created await pool.query(format(`DROP SCHEMA %I CASCADE;`, this.roleName)); await pool.query(format(`DROP ROLE %I;`, this.roleName)); // Disconnect await pool.close(); } } module.exports = Context;
이후, users.test.js
도 업데이트한다:
javascriptconst request = require("supertest"); const buildApp = require("../../app"); const UserRepo = require("../../repos/user-repo"); const pool = require("../../pool"); const Context = require("../context"); let context; beforeAll(async () => { context = await Context.build(); }); afterAll(() => { return context.close(); }); it("create a user", async () => { const startingCount = await UserRepo.count(); await request(buildApp()) .post("/users") .send({ username: "testuser", bio: "test bio" }) .expect(200); const finishCount = await UserRepo.count(); expect(finishCount - startingCount).toEqual(1); });
이후, 다른 test 파일들에도 같은 방식으로 Context
를 사용해 로직을 만들고 npm run test
를 실행하면 모든 파일이 정상적으로 테스트가 진행되고, 새로 만든 스키마와 role들도 없어지는 것을 확인할 수 있다.
테스트에서 추가하는 데이터가 있다면, 리셋하는 로직도 필요하다. 아래는 이 경우 Context
클래스에 추가할 reset
메소드이다.
javascriptasync reset() { return pool.query(` DROP FROM users; `); }
그리고, 아래는 테스트 파일들에 들어갈 beforeEach
다.
javascriptbeforeEach(async () => { await context.reset(); });