노션클론 리팩토링 (17) - Repository Layer과 테스트

김영현·2025년 1월 24일
0

Repository Layer + Interface

Service계층의 단위테스트와 통합테스트를 진행하고싶었다.
접근하는 계층을 분리하지 않아서 단위테스트의 어려움이 있었다.

그래서 Service계층에서 데이터베이스 관련 로직인 Repository계층을 분리하였다.
최근 DI용어에 대해 알게되면서 이참에 클래스로 작성해보는게 어떨까 싶었다.

class DocumentsRepository{
	constructor(db){
    	this.db = db;
    }
}

아예 Service계층도 클래스로 작성해 의존성을 주입하여 사용하였다.

const documentsService = new DocumentsService(new DocumentsRepository());

Repository계층을 분리할때 이점은 이 뿐만이 아니다. 다양한 db를 의존성으로 주입받으므로, db를 변경할때 코드를 수정할 필요가 없다고 한다. 그런데 해당 부분에 의문이 있었다.

DI는 의존성 주입이다. 의존성 주입은 인터페이스를 강제한다. 따라서 Repository계층에 주입하는 db는 모두 같은 메서드를 가지고 있어야한다.

그러나 nosql인 mongodb는 collection을 갖고있고, sql인 mysqltable개념을 갖고있다.

const getDocument = db.collection('컬렉션이름').findOne({title:'제목없음'}); //mongodb node driver을 사용한 모습

const getDocument = connection.query("SELECT * FROM document WHERE title = '제목없음'");

위처럼 각 db모듈에서 사용하는 메서드가 달라진다. 따라서 해당 인터페이스를 통일해야 Repository의 기능을 잘 사용한다고 볼 수 있지 않나?

그래서 찾아봤는데 생각한 내용이 고대로 있었다.


출처 : https://dev.to/ivanzm123/change-the-db-as-you-wish-repository-pattern-2h2h

각 db들의 Repository를 특정한 인터페이스를 이용하여 구현한다.

혼자 사고해 내놓은 답이 맞아서 조금 기뻤다.


Repository Layer Test

단위테스트는 많을수록 좋고, 통합테스트는 단위테스트보다 적어야한다.
따라서 바닥 계층인 Repository의 단위테스트 부터 작성하려 했다.

그런데 Repository계층의 로직은 아주 단순하다. db에 연결해 db와 관련된 특정한 메서드를 실행한다.

class DocumentsRepository {
  constructor(db) {
    this.db = db;
  }
  
  async getDocuments() {
    return await this.db.collection(DOCUMENTS_COLLECTION).find().toArray();
  }
//...기타 로직들
}

만약 getDocuments()를 테스트하고싶다면 test double을 이용하여 테스트할 것이다.
개중 간단하게 처리 가능한 mock을 사용해보자.

describe("DocumentsRepository", () => {
  let mockDb, documentsRepository;

  beforeEach(() => {
    //db 모킹
    mockDb = {
      collection: jest.fn().mockReturnThis(),
      find: jest.fn().mockReturnThis(),
      toArray: jest.fn(),
    };
    documentsRepository = new DocumentsRepository(mockDb);
  });

  it("should return documents from the collection", async () => {
    const mockDocuments = [{ id: 1, name: "Doc1" }, { id: 2, name: "Doc2" }];
    mockDb.toArray.mockResolvedValue(mockDocuments);

    const result = await documentsRepository.getDocuments();

    expect(mockDb.collection).toHaveBeenCalledWith(DOCUMENTS_COLLECTION);
    expect(mockDb.find).toHaveBeenCalled();
    expect(mockDb.toArray).toHaveBeenCalled();
    expect(result).toEqual(mockDocuments);
  });
});

현재 사용중인 db는 mongoDB다. 해당 db의 기능을 모킹했는데, 좀 불편해보인다.
Repository계층의 각 메서드마다 db 메서드를 모킹해주기 귀찮으므로 mongodb-memory-server같은 인메모리 기반 테스트DB서버를 구축하여 사용할 수도 있다.

그런데 db의 실제기능을 그대로 가져온 것이면, 통합테스트라고 봐도 무방하지 않을까?
단위테스트는 test double을 이용하여 내부에서 호출하는 다양한 객체와 메서드의 값을 성공한 것이라고 속이는게 핵심이다. 인메모리서버를 이용하면 단위테스트라고 부르기 어렵다.

따라서 귀찮더라도 각 메서드마다 사용되는 db의 기능을 모킹하는 것이 올바를까?

곰곰히 생각해본 결과, 아래의 이유로 인해 mongodb의 기능을 모킹해서까지 단위테스트를 할 필요는 없다고 생각한다.

  1. 쿼리문이 단순하고, 로직이 단순하다. mongodb driver 메서드를 래핑한 것에 가깝다.
  2. db관련 로직은 인메모리 db를 이용한 통합테스트가 훨씬 실용적으로 보인다. 쿼리문이 원하는 결과를 냈는지가 중요하기 때문이다.
  3. 각 메서드마다 사용되는 db의 기능을 모킹하기 귀찮다.

그러므로 Repository계층의 단위테스트는 생략하고 곧바로 통합테스트를 진행해보자.

통합테스트

const { MongoClient } = require("mongodb");

const { DatabaseConstants } = require("../../../constants/database");
const DocumentsRepository = require("./documentsRepository");

const { DOCUMENTS_COLLECTION, COUNTERS_COLLECTION, SEQUENCE_VALUE } =
  DatabaseConstants;

describe("DocumentsRepository Tests", () => {
  let connection;
  let db;
  let documentsRepository;

  beforeAll(async () => {
    connection = await MongoClient.connect(globalThis.__MONGO_URI__, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });

    db = await connection.db(globalThis.__MONGO_DB_NAME__);

    documentsRepository = new DocumentsRepository(db);
  });

  beforeEach(async () => {
    await db.collection(DOCUMENTS_COLLECTION).deleteMany({});
    await db.collection(COUNTERS_COLLECTION).deleteMany({});
  });

  afterAll(async () => {
    await connection.close();
  });

  it("get new Document Id", async () => {
    const result = [];
    const count = Array(5).fill(null);
    for await (const _ of count) {
      const newId = (await documentsRepository.getNewDocumentId())[
        SEQUENCE_VALUE
      ];
      result.push(newId);
    }

    expect(result).toEqual([1, 2, 3, 4, 5]);
  });

  it("create and get document by id", async () => {
    const newDocument = {
      _id: 1,
      title: "Test Document",
      content: "This is a test document.",
      materializedPath: "1,",
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await documentsRepository.createDocument(newDocument);
    const fetchedDocument = await documentsRepository.getDocumentById(1);

    expect(fetchedDocument).toEqual({
      title: "Test Document",
      content: "This is a test document.",
      materializedPath: "1,",
      createdAt: newDocument.createdAt,
      updatedAt: newDocument.updatedAt,
    });
  });

  it("update document", async () => {
    const newDocument = {
      _id: 1,
      title: "Test Document",
      content: "This is a test document.",
      materializedPath: "1,",
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await documentsRepository.createDocument(newDocument);

    const updatedDocument = {
      title: "Updated Document",
      content: "This is an updated test document.",
      updatedAt: new Date(),
    };

    await documentsRepository.updateDocument(1, updatedDocument);
    const fetchedDocument = await documentsRepository.getDocumentById(1);

    expect(fetchedDocument).toEqual({
      title: "Updated Document",
      content: "This is an updated test document.",
      materializedPath: "1,",
      createdAt: newDocument.createdAt,
      updatedAt: updatedDocument.updatedAt,
    });
  });

  it("get all documents", async () => {
    const documents = [
      {
        _id: 1,
        title: "Document 1",
        content: "Content 1",
        materializedPath: "1,",
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        _id: 2,
        title: "Document 2",
        content: "Content 2",
        materializedPath: "2,",
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ];

    await documentsRepository.createDocument(documents[0]);
    await documentsRepository.createDocument(documents[1]);

    const fetchedDocuments = await documentsRepository.getDocuments();

    expect(fetchedDocuments).toEqual(documents);
  });

  it("delete document", async () => {
    const newDocument = {
      _id: 1,
      title: "Test Document",
      content: "This is a test document.",
      materializedPath: "1,",
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await documentsRepository.createDocument(newDocument);
    await documentsRepository.deleteDocument(1);
    const fetchedDocument = await documentsRepository.getDocumentById(1);

    expect(fetchedDocument).toBeNull();
  });

  it("get child documents", async () => {
    const parentDocument = {
      _id: 1,
      id: 1,
      title: "Parent Document",
      content: "This is a parent document.",
      materializedPath: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    const childDocument = {
      _id: 2,
      id: 2,
      title: "Child Document",
      content: "This is a child document.",
      materializedPath: ",1,",
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await documentsRepository.createDocument(parentDocument);
    await documentsRepository.createDocument(childDocument);

    const fetchedChildDocuments = await documentsRepository.getChildDocuments(
      1
    );

    expect(fetchedChildDocuments).toEqual([childDocument]);
  });
});

DocumentsRepository에 인메모리db를 종속성으로 주입해서 테스트를 진행하였다.


느낀점

굳이 할 필요 없는 테스트란 것도 존재할까? 얻어낸 결론에 따르면 그렇다.
예를들어 아주 단순한 레포지토리계층의 단위테스트는 불필요한 테스트 코드를 작성하는 것과 같다.
하지만 이마저도 꼼꼼히 작성해야하는게 테스트의 의의 아닐까 싶기도 하다. 아직은 잘 모르겠다.

profile
모르는 것을 모른다고 하기

0개의 댓글