Cloud Functions의 단위 테스트

이 페이지에서는 지속적 통합(CI)에 포함될 테스트와 같이 함수용 단위 테스트 작성에 대한 권장사항과 도구를 설명합니다. 쉽게 테스트할 수 있도록 Firebase에서 Cloud Functions용 Firebase Test SDK를 제공합니다. 이는 firebase-functions-test로 npm에 배포되며 firebase-functions에 대한 동반 테스트 SDK입니다. Cloud Functions용 Firebase Test SDK로 다음 작업을 할 수 있습니다.

  • firebase-functions에 필요한 환경 변수를 설정 및 설정 해제하는 등 적절한 테스트 설정과 해제를 처리합니다.
  • SDK가 샘플 데이터와 이벤트 컨텍스트를 생성하므로 테스트와 관련된 필드만 지정하면 됩니다.

테스트 설정

함수 폴더에서 다음 명령어를 실행하여 firebase-functions-test 및 테스트 프레임워크인 Mocha를 모두 설치합니다.

npm install --save-dev firebase-functions-test
npm install --save-dev mocha

다음으로 함수 폴더 내에 test 폴더를 만들고 여기에 테스트 코드용으로 새 파일을 만든 후 index.test.js와 같은 이름을 지정합니다.

마지막으로 functions/package.json을 수정하여 다음을 추가합니다.

"scripts": {
  "test": "mocha --reporter spec"
}

테스트를 작성한 후에는 함수 디렉터리 내에서 npm test를 실행하여 테스트를 실행할 수 있습니다.

Cloud Functions용 Firebase Test SDK 초기화

firebase-functions-test를 사용하는 방법에는 두 가지가 있습니다.

  1. 온라인 모드(권장): 데이터베이스 쓰기, 사용자 생성 등이 실제로 일어나고 테스트 코드로 결과를 검사할 수 있도록 테스트 전용 Firebase 프로젝트와 상호작용하는 테스트를 작성합니다. 즉, 함수에서 사용하는 다른 Google SDK도 작동하게 됩니다.
  2. 오프라인 모드: 부작용 없이 격리된 오프라인 단위 테스트를 작성합니다. 즉, Firebase 제품과 상호작용하는 모든 메서드 호출(예: 데이터베이스 쓰기 또는 사용자 생성)이 스텁 처리되어야 합니다. Cloud Firestore 또는 실시간 데이터베이스 함수가 있으면 테스트 코드의 복잡성이 크게 증가하므로 일반적으로 오프라인 모드를 사용하지 않는 것이 좋습니다.

온라인 모드에서 SDK 초기화(권장)

테스트 프로젝트와 상호작용하는 테스트를 작성하려면 firebase-admin을 통해 앱을 초기화하는 데 필요한 프로젝트 구성 값과 서비스 계정 키 파일의 경로를 제공해야 합니다.

Firebase 프로젝트의 구성 값을 가져오는 방법은 다음과 같습니다.

  1. Firebase Console에서 프로젝트 설정을 엽니다.
  2. 내 앱에서 원하는 앱을 선택합니다.
  3. 오른쪽 창에서 Apple 및 Android 앱용 구성 파일을 다운로드하는 옵션을 선택합니다.

    웹 앱의 경우 구성을 선택하여 구성 값을 표시합니다.

키 파일을 만드는 방법은 다음과 같습니다.

  1. Google Cloud Console의 서비스 계정 창을 엽니다.
  2. App Engine 기본 서비스 계정을 선택하고 오른쪽에 있는 옵션 메뉴를 사용하여 키 만들기를 선택합니다.
  3. 메시지가 나타나면 키 유형으로 JSON을 선택하고 만들기를 클릭합니다.

키 파일을 저장한 후 다음과 같이 SDK를 초기화합니다.

// At the top of test/index.test.js
const test = require('firebase-functions-test')({
  databaseURL: 'https://my-project.firebaseio.com',
  storageBucket: 'my-project.appspot.com',
  projectId: 'my-project',
}, 'path/to/serviceAccountKey.json');

오프라인 모드에서 SDK 초기화

완전 오프라인 테스트를 작성하려면 매개변수 없이 SDK를 초기화하면 됩니다.

// At the top of test/index.test.js
const test = require('firebase-functions-test')();

구성 값 모의 처리

함수 코드에서 functions.config()를 사용하는 경우 구성 값을 모의 처리할 수 있습니다. 예를 들어 functions/index.js에 다음 코드가 있는 경우가 여기에 해당합니다.

const functions = require('firebase-functions');
const key = functions.config().stripe.key;

그 후 다음과 같이 테스트 파일 내의 값을 모의 처리할 수 있습니다.

// Mock functions config values
test.mockConfig({ stripe: { key: '23wr42ewr34' }});

함수 가져오기

함수를 가져오려면 require를 사용하여 기본 함수 파일을 모듈로 가져옵니다. firebase-functions-test를 초기화하고 구성 값을 모의 처리한 후에만 가져와야 합니다.

// after firebase-functions-test has been initialized
const myFunctions = require('../index.js'); // relative path to functions code

오프라인 모드에서 firebase-functions-test를 초기화했고 함수 코드에 admin.initializeApp()이 있다면 함수를 가져오기 전에 스텁 처리해야 합니다.

// If index.js calls admin.initializeApp at the top of the file,
// we need to stub it out before requiring index.js. This is because the
// functions will be executed as a part of the require process.
// Here we stub admin.initializeApp to be a dummy function that doesn't do anything.
adminInitStub = sinon.stub(admin, 'initializeApp');
// Now we can require index.js and save the exports inside a namespace called myFunctions.
myFunctions = require('../index');

HTTP가 아닌 백그라운드 함수 테스트

HTTP가 아닌 함수를 테스트하는 절차는 다음과 같습니다.

  1. test.wrap 메서드로 테스트할 함수를 래핑합니다.
  2. 테스트 데이터를 구성합니다.
  3. 구성한 테스트 데이터 및 지정할 이벤트 컨텍스트 필드를 포함해 래핑한 함수를 호출합니다.
  4. 동작에 대한 어설션을 만듭니다.

먼저 테스트할 함수를 래핑합니다. functions/index.jsmakeUppercase라는 테스트할 함수가 있다고 가정해 보겠습니다. functions/test/index.test.js에 다음 코드를 작성합니다.

// "Wrap" the makeUpperCase function from index.js
const myFunctions = require('../index.js');
const wrapped = test.wrap(myFunctions.makeUppercase);

wrapped는 호출 시 makeUppercase를 호출하는 함수입니다. wrapped는 매개변수 2개를 사용합니다.

  1. data(필수사항): makeUppercase로 보낼 데이터입니다. 작성한 함수 핸들러로 전송되는 첫 번째 매개변수에 직접적으로 해당합니다. firebase-functions-test가 커스텀 데이터나 예시 데이터 구성을 위한 메서드를 제공합니다.
  2. eventContextOptions(선택사항): 지정할 이벤트 컨텍스트의 필드입니다. 이벤트 컨텍스트는 작성한 함수 핸들러로 전송되는 두 번째 매개변수입니다. wrapped를 호출할 때 eventContextOptions 매개변수를 포함하지 않아도 알맞은 필드로 이벤트 컨텍스트가 생성됩니다. 여기에서 지정하면 생성된 필드 중 일부를 재정의할 수 있습니다. 재정의할 필드만 포함해야 한다는 점에 유의하세요. 재정의하지 않은 모든 필드는 생성됩니다.
const data = … // See next section for constructing test data

// Invoke the wrapped function without specifying the event context.
wrapped(data);

// Invoke the function, and specify params
wrapped(data, {
  params: {
    pushId: '234234'
  }
});

// Invoke the function, and specify auth and auth Type (for real time database functions only)
wrapped(data, {
  auth: {
    uid: 'jckS2Q0'
  },
  authType: 'USER'
});

// Invoke the function, and specify all the fields that can be specified
wrapped(data, {
  eventId: 'abc',
  timestamp: '2018-03-23T17:27:17.099Z',
  params: {
    pushId: '234234'
  },
  auth: {
    uid: 'jckS2Q0' // only for real time database functions
  },
  authType: 'USER' // only for real time database functions
});

테스트 데이터 구성

래핑한 함수의 첫 번째 매개변수는 기본 함수를 호출할 때 쓰이는 테스트 데이터입니다. 테스트 데이터는 여러 방법으로 구성할 수 있습니다.

커스텀 데이터 사용

firebase-functions-test에는 함수 테스트에 필요한 데이터를 구성하는 여러 함수가 있습니다. 예를 들어 test.firestore.makeDocumentSnapshot을 사용하여 Firestore DocumentSnapshot을 만듭니다. 첫 번째 인수는 데이터, 두 번째 인수는 전체 참조 경로입니다. 스냅샷의 기타 속성을 직접 지정할 수 있는 세 번째 인수(선택사항)도 있습니다.

// Make snapshot
const snap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Call wrapped function with the snapshot
const wrapped = test.wrap(myFunctions.myFirestoreDeleteFunction);
wrapped(snap);

onUpdate 또는 onWrite 함수를 테스트하는 경우 이전 상태와 이후 상태용으로 하나씩 스냅샷 2개를 만들어야 합니다. 그런 다음 makeChange 메서드를 사용하여 이러한 스냅샷과 함께 Change 객체를 만들 수 있습니다.

// Make snapshot for state of database beforehand
const beforeSnap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Make snapshot for state of database after the change
const afterSnap = test.firestore.makeDocumentSnapshot({foo: 'faz'}, 'document/path');
const change = test.makeChange(beforeSnap, afterSnap);
// Call wrapped function with the Change object
const wrapped = test.wrap(myFunctions.myFirestoreUpdateFunction);
wrapped(change);

다른 모든 데이터 유형의 비슷한 함수는 API 참조를 확인하세요.

예시 데이터 사용

테스트에 사용하는 데이터를 맞춤설정할 필요가 없다면 firebase-functions-test는 각 함수 유형에 대한 예시 데이터를 생성하는 메서드를 제공합니다.

// For Firestore onCreate or onDelete functions
const snap = test.firestore.exampleDocumentSnapshot();
// For Firestore onUpdate or onWrite functions
const change = test.firestore.exampleDocumentSnapshotChange();

모든 함수 유형의 예시 데이터를 가져오는 메서드는 API 참조를 확인하세요.

스텁 처리된 데이터 사용(오프라인 모드용)

오프라인 모드에서 SDK를 초기화한 후 Cloud Firestore 또는 실시간 데이터베이스 함수를 테스트할 경우 실제 DocumentSnapshot 또는 DataSnapshot을 만드는 대신 스텁이 있는 일반 객체를 사용해야 합니다.

다음 함수에 대한 단위 테스트를 작성한다고 가정해 보겠습니다.

// Listens for new messages added to /messages/:pushId/original and creates an
// uppercase version of the message to /messages/:pushId/uppercase
exports.makeUppercase = functions.database.ref('/messages/{pushId}/original')
    .onCreate((snapshot, context) => {
      // Grab the current value of what was written to the Realtime Database.
      const original = snapshot.val();
      functions.logger.log('Uppercasing', context.params.pushId, original);
      const uppercase = original.toUpperCase();
      // You must return a Promise when performing asynchronous tasks inside a Functions such as
      // writing to the Firebase Realtime Database.
      // Setting an "uppercase" sibling in the Realtime Database returns a Promise.
      return snapshot.ref.parent.child('uppercase').set(uppercase);
    });

함수 내에서 snap이 2번 사용됩니다.

  • snap.val()
  • snap.ref.parent.child('uppercase').set(uppercase)

테스트 코드에서 이러한 코드 경로가 모두 작동하는 일반 객체를 만들고 Sinon을 사용하여 메서드를 스텁 처리합니다.

// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);

어설션 만들기

SDK를 초기화하고 함수를 래핑하고 데이터를 구성한 후 구조화된 데이터로 래핑한 함수를 호출하고 동작에 대한 어설션을 만들 수 있습니다. 이러한 어설션을 만드는 데 Chai와 같은 라이브러리를 사용할 수 있습니다.

온라인 모드에서 어설션 만들기

온라인 모드에서 Cloud Functions용 Firebase Test SDK를 초기화한 경우 firebase-admin SDK를 사용하여 원하는 작업(예: 데이터베이스 쓰기)을 수행했다고 어설션을 만들 수 있습니다.

아래 예시에서는 테스트 프로젝트의 데이터베이스에 'INPUT'을 썼다는 어설션을 만듭니다.

// Create a DataSnapshot with the value 'input' and the reference path 'messages/11111/original'.
const snap = test.database.makeDataSnapshot('input', 'messages/11111/original');

// Wrap the makeUppercase function
const wrapped = test.wrap(myFunctions.makeUppercase);
// Call the wrapped function with the snapshot you constructed.
return wrapped(snap).then(() => {
  // Read the value of the data at messages/11111/uppercase. Because `admin.initializeApp()` is
  // called in functions/index.js, there's already a Firebase app initialized. Otherwise, add
  // `admin.initializeApp()` before this line.
  return admin.database().ref('messages/11111/uppercase').once('value').then((createdSnap) => {
    // Assert that the value is the uppercased version of our input.
    assert.equal(createdSnap.val(), 'INPUT');
  });
});

오프라인 모드에서 어설션 만들기

예상된 함수 반환 값에 대한 어설션을 만들 수 있습니다.

const childParam = 'uppercase';
const setParam = 'INPUT';
// Stubs are objects that fake and/or record function calls.
// These are excellent for verifying that functions have been called and to validate the
// parameters passed to those functions.
const childStub = sinon.stub();
const setStub = sinon.stub();
// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);
// Wrap the makeUppercase function.
const wrapped = test.wrap(myFunctions.makeUppercase);
// Since we've stubbed snap.ref.parent.child(childParam).set(setParam) to return true if it was
// called with the parameters we expect, we assert that it indeed returned true.
return assert.equal(wrapped(snap), true);

또한 Sinon Spies를 사용하여 특정 메서드가 호출되었다는 어설션을 만들고 예상되는 매개변수를 추가할 수 있습니다.

HTTP 함수 테스트

HTTP onCall 함수를 테스트하려면 백그라운드 함수 테스트와 같은 방식을 사용합니다.

HTTP onRequest 함수를 테스트한다면 다음과 같은 경우에 firebase-functions-test를 사용해야 합니다.

  • functions.config()를 사용하는 경우
  • 함수가 Firebase 프로젝트나 다른 Google API와 상호작용하는 경우 및 테스트에 실제 Firebase 프로젝트 및 사용자 인증 정보를 사용하려는 경우

HTTP onRequest 함수는 요청 객체 및 응답 객체라는 2가지 매개변수를 취합니다. addMessage() 예시 함수를 테스트하는 방법은 다음과 같습니다.

  • sendMessage()에서 호출하므로 응답 객체에서 리디렉션 함수를 재정의합니다.
  • 리디렉션 함수 내에서 chai.assert를 사용하면 리디렉션 함수를 호출할 매개변수에 대한 어설션을 만들 수 있습니다.
// A fake request object, with req.query.text set to 'input'
const req = { query: {text: 'input'} };
// A fake response object, with a stubbed redirect function which asserts that it is called
// with parameters 303, 'new_ref'.
const res = {
  redirect: (code, url) => {
    assert.equal(code, 303);
    assert.equal(url, 'new_ref');
    done();
  }
};

// Invoke addMessage with our fake request and response objects. This will cause the
// assertions in the response object to be evaluated.
myFunctions.addMessage(req, res);

테스트 정리

테스트 코드의 맨 끝에서 정리 함수를 호출합니다. 그러면 초기화할 때 SDK에서 설정한 환경 변수가 설정 해제되며 SDK로 실시간 데이터베이스 DataSnapshot이나 Firestore DocumentSnapshot을 만든 경우에 생성되었을 수 있는 Firebase 앱이 삭제됩니다.

test.cleanup();

전체 예시 검토 및 자세히 알아보기

Firebase GitHub 저장소에서 전체 예시를 검토할 수 있습니다.

자세한 내용은 firebase-functions-test에 대한 API 참조를 확인하세요.