Cloud Firestoreのrulesをテストする

Komerco事業部エンジニアの岸本(id: sgrksmt)です。今日でちょうど入社1年が経ち、現在Komerco -コメルコ-(以下、Komerco)の開発を担当しています。
入社前はお世話になっていたこの技術ブログに自分が投稿する日がくるとは...。

Komercoは、「料理が楽しくなるマルシェアプリ」というコンセプトの元、料理が楽しくなる器やカトラリー、リネン雑貨等を出品/購入できるサービスで、現在はiOS版のアプリケーションを提供しています。

今年2月のCookpad Tech Conf2018や先日催したCookpad Tech Kitchen#16などでもお伝えしてきていますが、現在KomercoではバックエンドでFirebaseを活用しています。
その中で、最近僕が仕組みづくりとして取り組んでいるCloud Firestoreのセキュリティルールのテストの方法についてご紹介します。

Cloud Firestoreのrules

Cloud Firestore(以下、Firestore)では、各プラットフォームで提供しているSDKやREST API経由でデータを安全に読み書きできるよう、セキュリティルールを記述することができます。
主にFirebase Authenticationでの認証を活用しつつ、どのような条件下でドキュメントを読み取ることができるか、書き込むことができるかを設定し、ユーザーのデータを保護します。
また余談にはなりますが、Realtime DBとCloud Storageにもセキュリティルールがあります。

セキュリティルールをしっかり設定しないと、本来読み取られてはいけないデータが悪意のある第三者に読み取られてしまったり、特定のフィールドを書き換えられてしまうといったことが起こります。
一方で、それを防ぐために全てのルールを閉じて、API経由でのみ読み書きできるようにして堅牢にすることも可能ですが、そうするとCloud Firestoreの次のような利点を享受しにくくなります。

  • リアルタイムでの情報の取得
  • オフラインから復帰したときに、ローカルでの変更を書き込む

なので、実際にアプリケーション開発をしていく場合は全ルールを閉じる運用よりは、ルールを適切に設定して運用していくことが多くなるかと思います。

Firestore rulesの確認が大変

Firestoreのrulesを書いて、期待通りに動作するのかを確かめつつ開発をしていきますが、これが非常に 大変かつ面倒 だったりします。 変更をした後、毎回デプロイして、浸透するまで数分待って、正しく動作するかクライアント側で動作させて確かめるのはかなり非効率ですし、 エラーの内容も「permission-denied」程度のものしかないので何が原因か掴みづらいです。
一応、rulesの構文自体が間違っているかどうかはデプロイ前に検出してくれるので、構文がおかしくなっている場合は気づけますが、typoなんてしてしまった日にはなかなか気づきづらく、デプロイと確認だけで日が暮れてしまうこともありえます。
"もっと効率的にrulesを書いて確認したい..."

そう思いながら今年の前半を過ごしていたのですが、2018年の5月末頃にFirestore rulesの動作を書き込む前に確認できる「シミュレータ」の機能がFirebaseのコンソール上に搭載されました。

rulesのシミュレーター

Firestoreのrulesシミュレーターは、FirestoreのProjectからDatabase→Firestore→ルールとたどり、この部分をクリックすることで利用することができます。

f:id:sgrksmt:20180815180611p:plain

開くと、このような画面になっており、指定したドキュメントのパスに対して、readやwriteのルールを記述されたルールを基にシミュレートして確認することができます。

f:id:sgrksmt:20180815180716p:plain

また、認証情報も指定したテストができるので、認証ありきの条件もシミュレートすることが可能です。
以前私がQiitaに掲載した記事でも操作方法など書いていますのでよければ併せて御覧ください。

シミュレータを用いると以下のメリットがあります。

  • 公開前に条件をシミュレートして確認ができるので、誤った条件のものをデプロイしてしまう心配がない
  • すぐにシミュレートして試せるので、デプロイしてから数分待って、、といった具合に時間をロスすることがない
  • 失敗した場合に、何が原因でどこで失敗しているのか指摘してくれるので、原因がわかりやすい
  • 書き込みに関するシミュレートの場合、実際のDBに書き込みが行われるわけではないので、DBを汚してしまうことがない

ただ、シミュレータでのrulesの動作の確認はコンソール上で簡単に確認ができる反面、以下のデメリットがあります

  • listのオペレーションに関して、queryに関する条件の確認ができない
  • getAfter 関数を用いた条件の確認ができない
  • 2つ以上のドキュメントの書き込みに関するテストができない
  • 数百行になってくるとコンソールがやや重たくなるので編集時にストレスがかかる

今後開発を進めていく上で、継続的にrulesが正しく動作するかを確かめられる環境がないと、新機能の追加や大幅な改修、リファクタリングに耐えられないので、
Firestoreのrulesが正しく動くかどうか のテストを構築していくことにしました。


Firestore rulesのテスト

ここからが本題 になります。大まかな流れとしては

  • 開発環境、本番環境とは別の、 テスト環境 用のFirebase Projectを準備する
  • テスト環境にfirestore.rulesファイルをにデプロイする。
  • テストを書き、実際にテスト環境に対してドキュメントの読み書きを行い、Firestore rulesが期待通りの動作をするかをテストする
  • 手元でテストを実行できる他、CI経由でも継続的にテストが行えるようにする

となります。順に、簡単なテストの例も交えつつ説明していきます。

構成

テストの構成としては、次のようになっています。

f:id:sgrksmt:20180815180802p:plain

普段のアプリケーション開発では開発環境用に必要なものをデプロイしたり、DBの読み書きを行っています。
テストのときは、 テスト用 のFirebase Projectを準備し、そこにrulesをデプロイしています。
そして、テストを実行するときは、向き先をテスト環境のFirebase Projectにし、その環境のFirestoreのDBに対して読み書きを実行し、rulesのテストをします。
また、テストは手元からCLI経由で実行する他に、GitHub上にPullRequestが作成された時や、materブランチにマージされたタイミングで、CI経由でテストを実行するようにしています。

また、この記事では詳しく触れませんが、Komercoでは同様に、CloudFunctionsに関連するテストも同様に、テスト環境のFirebase Project上で行っています。

準備

テストを書くにあたり、jestを利用しているので、npmもしくはyarnにて追加します。
jestは、Facebook社がOSSとして提供している、JavaScriptでユニットテストを行うためのフレームワークです。RSpecのような記述が可能となります。
今回はjestを使った場合でのテストの紹介となりますが、テストのフレームワークは任意のものでも構いません。

また、テストに関連するファイルは、 test/ ディレクトリ以下に配置していきます。 KomercoではCloud Functionsのテストもあるので、 test/rules/ ディレクトリ以下に配置しています。

Firebaseの初期化をする

テストで使うFirebaseの初期化をします。
Cloud Functions等でFirebaseを扱うときは、adminSDKが使えるようadmin権限での初期化をすることが多いのですが、
admin権限でFirebaseを初期化してしまうと、設定したrulesに関係なく管理者権限にて読み書きが可能となってしまうので、Webアプリケーションと同様の初期化を行います。

webhelper.ts を任意のテストディレクトリ以下に配置し、以下のように記述します。

import * as firebase from 'firebase'

const config = {
  apiKey: 'API_KEY',
  authDomain: 'AUTH_DOMAIN',
  databaseURL: 'DATABASE_URL',
  projectId: 'PROJECT_ID',
  storageBucket: 'STORAGE_BUCKET',
  messagingSenderId: 'SERNDER_ID'
}

firebase.initializeApp(config)

const auth = firebase.auth()
const firestore = firebase.firestore()
const settings = { timestampsInSnapshots: true }
firestore.settings(settings)

export { firebase, auth, firestore }

各種configの変数はご自身のテスト環境用のプロジェクトのIDを指定してください。(基本的には.env等から参照することになると思います。)
これら初期化した変数は次のようにテストファイルでimportして使用します。

import * as WebHelper from './helper/webhelper'

const postRef = WebHelper.firestore.collection('post').doc()

モデルを定義する

ドキュメントのモデル定義をします。
今回は例として、Postドキュメントの定義をします。

export enum Path {
  Post = '/posts'
}
export interface Post {
  title: string,
  body: string,
  authorID: string,
  isPublished: boolean
}

次項では、このPostドキュメントのcreateオペレーションに関するテストの例をご紹介します。
(すべてのオペレーションの例を紹介したいのですが、長くなるので割愛します。)

テストを書いていく

前提条件として

  • Firebase Authenticationにて認証されたユーザー
  • 定義したパラメータを全て有している
  • post.authorID が、認証ユーザーのuidと一致する

という条件のもと、書き込みが正常に行われるのを期待するケースと、失敗し、permission-deniedがエラーとして返却されるのを期待するケースを記述します。
(失敗するケースは複数想定されますが、ここでは1つのケースに絞ります。)
posts.test.tsを作成し、次のように記述します。

import * as WebHelper from './helper/webhelper'

enum Path {
  Post = '/posts'
}
interface Post {
  title: string,
  body: string,
  authorID: string,
  isPublished: boolean
}

const makePostDocument = (authorID: string) => {
  return <Post>{
    title: 'test post',
    body: 'test post body',
    authorID: authorID,
    isPublished: true
  }
}

const permissionDeniedError = { code: 'permission-denied' }

describe('post document rules', () => {
  jest.setTimeout(10000)

  let postCollectionRef: WebHelper.firebase.firestore.CollectionReference
  beforeAll(() => {
    postCollectionRef = WebHelper.firestore.collection(Path.Post)
  })

  describe('write', () => {
    describe('create', async () => {
      let authUser: any

      beforeEach(async () => {
        authUser = await WebHelper.auth.signInAnonymously()
      })

      afterEach(async () => {
        await WebHelper.auth.signOut()
      })

      describe('when authorID is equal to auth.uid', () => {
        test('should be succeeded', async () => {
          const post = makePostDocument(authUser.user.uid)
          await expect(postCollectionRef.doc().set(post)).resolves.toBeUndefined()
        })
      })

      describe('when authorID is not equal to auth.uid', () => {
        test('should be failed', async () => {
          expect.assertions(1)
          const post = makePostDocument('xxxxxxxxxxx')
          await expect(postCollectionRef.doc().set(post)).rejects.toMatchObject(permissionDeniedError)
        })
      })
    })
  })
})

(モックデータの作成に関する処理、モデル定義等は別途別のファイルに分割するほうが良いですが、今回は例を示すために同一のファイル内に置いています。) ここまでで実行すると、rulesがまだ書けていない場合はテストが通らないと思います。
次にこのテストを通過するための正しいrulesを記述します。

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function incomingData() {
      return request.resource.data;
    }

    match /posts/{postID} {
      allow create: if isAuthenticated()
                    && incomingData().keys().hasAll(requiredFields())
                    && incomingData().authorID == request.auth.uid;

      function requiredFields() {
        return ['title', 'body', 'authorID', 'isPublished'];
      }
    }
  }
}

これをテスト環境にデプロイし、再度テストを実行することで、テストが通るようになります。
(注: 紹介のため割愛しているテストケースがありますので、実際にはもう少しテストケースが多く、厚いものになると思います。また、rules自体も、各種フィールドのvalidationを行ったりと複雑になるかと思います)

事前にテストデータを作成する

テストを書いていると、セキュリティルールの制約を受けずにテストデータを作成したり、
CloudFunctionsで生成したり、事前にDBに格納しているのが前提で、読み出すだけのデータを準備したいケースがでてきます。
その場合はadmin権限でFirebaseを初期化したものを別途用意し、rulesの息のかからないところでテストデータを作成します。
adminhelper.ts というファイルを作成し、そこでAdmin SDKの初期化を行います。

import * as admin from 'firebase-admin'

admin.initializeApp({ credential: admin.credential.cert(require('admin sdk jsonのpath')) })
const auth = admin.auth()
const firestore = admin.firestore()
const settings = { timestampsInSnapshots: true }
firestore.settings(settings)

export { admin, auth, firestore }

(注: admin用のservice accountの取扱には注意です。 外部に漏れぬように 対策する必要があります)
参考: サーバーに Firebase Admin SDK を追加する

これにより、事前にPostドキュメントを作成し、そのPostドキュメントが取得可能かどうかのテストが次のように記述できます。

import * as WebHelper from './helper/webhelper'
import * as AdminHelper from './helper/adminhelper'

enum Path {
  Post = '/posts'
}
interface Post {
  title: string,
  body: string,
  authorID: string,
  isPublished: boolean
}

const permissionDeniedError = { code: 'permission-denied' }

const savePostDocument = async (isPublished: boolean) => {
  const postRef = AdminHelper.firestore.collection(Path.Post).doc()
  await postRef.set({
    title: 'test post',
    body: 'test post body',
    authorID: 'xxxxxxxx',
    isPublished: isPublished
  })
  return postRef
}

describe('post document rules', () => {
  jest.setTimeout(10000)

  let postCollectionRef: WebHelper.firebase.firestore.CollectionReference
  beforeAll(() => {
    postCollectionRef = WebHelper.firestore.collection(Path.Post)
  })

  describe('read', () => {
    describe('get', () => {
      afterEach(async () => {
        await WebHelper.auth.signOut()
      })

      describe('when user is authenticated', () => {
        describe('try to get valid document', () => {
          test('should be succeeded', async () => {
            await WebHelper.auth.signInAnonymously()
            const mockPostRef = await savePostDocument(true)
            await expect(postCollectionRef.doc(mockPostRef.id).get()).resolves.toBeDefined()
          })
        })

        describe('try to get invalid document', () => {
          test('should be failed', async () => {
            expect.assertions(1)
            await WebHelper.auth.signInAnonymously()
            const mockPostRef = await savePostDocument(false)
            await expect(postCollectionRef.doc(mockPostRef.id).get()).rejects.toMatchObject(permissionDeniedError)
          })
        })
      })

      describe('when user is not authenticated', () => {
        test('should be failed', async () => {
          expect.assertions(1)
          const mockPostRef = await savePostDocument(true)
          await expect(postCollectionRef.doc(mockPostRef.id).get()).rejects.toMatchObject(permissionDeniedError)
        })
      })
    })
  })

  // ...writeのrule
})

今回追加した、postドキュメントのgetに関するルールを次のように追加することで、上記テストが通るようになります。

service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function incomingData() {
      return request.resource.data;
    }

    function existingData() {
      return resource.data;
    }

    match /posts/{postID} {
      allow get: if isAuthenticated() && existingData().isPublished;
      allow create: if isAuthenticated()
                    && incomingData().keys().hasAll(requiredFields())
                    && incomingData().authorID == request.auth.uid;

      function requiredFields() {
        return ['title', 'body', 'authorID', 'isPublished'];
      }
    }
  }
}

料金面はどうか

実際にテストデータを作って、読み書きを行うので、コスト(料金)がかかるのでは、、と思われるかもしれませんが、
Komercoではrulesのテストに加え、Cloud Functionsのテストも実施しています。Pull Requestが作成される、あるいはmasterブランチが作成されたときにテストを実施していますが、 料金はほとんどそこまでかかっていません。

また、先にも触れましたが、テストを実行する環境と、普段開発している環境を分けているので、開発環境のDBが汚染されたりすることもありません。

Firestoreのルールのテストを書くことでのメリット

テストを書くことによるメリットとしては

  • アプリケーションを手動で動かしたり、シミュレータに頼ることなく(継続的に)rulesが正しく動作するか確かめることができる
  • rulesの変更が容易になる(書き換えた結果、正しくない場合にテストがあることで検知することができる。)

が挙げられます。普段のアプリケーション開発と同様で、テストがあることで、後の変更にも強くなり、rulesを変更することに臆することもなく、また高速に動作検証が行えるようになります。
また、事前にセキュリティを意識したドキュメントの設計がしやすくなり、見通しも良くなるので最近の開発では新機能の追加や改善に伴ってドキュメントの設計をするときは、同時にrulesのテストも記述し開発をしています。

さいごに

Firestoreのrulesに関して、より継続的に確認が行えるためのテストについてご紹介しました。
今回ご紹介したサンプルコードをGitHubにて公開していますので、よければ併せて御覧ください。

今後公式からテストする手段が提供される可能性もありますし、オフライン(ローカル)でのテストが提供される可能性もありますが、
現状Komercoではオンラインテストにて実施して、Firestore rulesの継続的なテストを行えるようにしています。
また、こうした事例に関わらず、Komerco開発チームではFirebaseと向き合って、その上でサービス提供ができるよう努めていきます。


クックパッドでは、Firebaseなど最新技術に興味がある!技術的に挑戦してサービスをより良くしたい!というエンジニアを募集しています。

自作キーボード沼 自由研究ノート

こんにちは!広報部のとくなり餃子大好き( id:tokunarigyozadaisuki )です。

クックパッドのSlackには無数のオープンチャンネルが存在していますが、最近盛り上がりを見せているのが「#keyboards」というチャンネル。先週末コミックマーケットが開催されていたためここ最近はその話でもちきりの様子でしたが、普段から大事な仕事道具であるキーボードにこだわりを持った社員が日々情報交換をしています。興味本位で社員のキーボードをのぞいでみると、ピカピカ光るものから、カチカチッと音がなるもの、アルファベットも数字も書いていないもの……その多彩さにびっくりします。 そこで、クックパッドエンジニアの最近のキーボード事情を調査してみました! 

なお、HHKBやRealforceはクックパッドでは当たり前だったので、紹介は割愛いたします。

クックパッドエンジニアのキーボード

@takai

f:id:tokunarigyozadaisuki:20180814150420j:plain

キーボード概要

名称:Keebio Fourier
スイッチ:Cherry MX 茶軸(45g)
レイアウト :40%スプリットキーボード 

なぜ今のキーボードにしたのか

自作するならスプリットキーボードで、かつコンパクトな感じに仕上げたいと思っていたところ、Keebio の Fourier を見つけて、「ミニマムでストイック、まさに自分のためのキーボードだ」と思いました。

気に入っているポイント

40%キーボードって、ほどよく不便で楽しいじゃないですか。数字キーが無いわけですから、使うにあたって工夫する必要があります。そこを自分好みにカスタマイズして使いこなしてると「俺すごい」という気持ちになれるんです。キーキャップのカラーリングもこだわりのポイントで、Signature Plastics社のGRANIT KEYSETにインスパイアされました。好みのレイアウトだと、キーキャップのセットが売っていなかったので、キーキャップ単位で購入したりと、そこは妥協せずに頑張りました。

@slightair

f:id:tokunarigyozadaisuki:20180814150437j:plain

キーボード概要

名称:Ergo42
スイッチ:Cherry MX 赤軸(45g)
レイアウト:7x4格子配列スプリットキーボード

なぜ今のキーボードにしたのか

少し前にスプリットキーボードに挑戦してみよう、でもキーが減るのはちょっと怖いなと思い、比較的キーが多いViterbi Keyboard(7x5格子配列)を作って使い始めたのですが、想像と違ってキーを余らせてしまっていました。 また同じキー配置で文字を打ちたいので、自宅と会社の間で持ち歩いていたのですが、面倒くさくなってきてもう一台作りたいなと考えていました。 そんなところに一行少ないだけでちょうどよく使えそうなErgo42の開発キットの販売がはじまったので飛びついてしまいました。国産なので注文してすぐ届きました。

気に入っているポイント

はんだ付けは大変ですが、キーキャップやキーマップなど、自分好みにいじることができるのが楽しいですね。特に、基板の底にLEDを配置して光らせるUnderglow(アンダーグロウ)が気に入っています。キーキャップの色は黒を基本に、特殊キーなどを青系にしていて、光の色もそれに合わせています。みんなから声かけられるようになったし、目を引く、やったぜ! という気持ちです。

@eisuke

f:id:tokunarigyozadaisuki:20180814150442j:plain

キーボード概要

名称:TMK Alps64
スイッチ:Alps SKCM SALMON
レイアウト:60%キーボード

なぜ今のキーボードにしたのか

ビンテージキーボードが好きで、以前はIBMマシンで使用されていたバックスプリング式キーボードを使っていました。60%くらいのコンパクトさで自分好みのキータッチのキーボードを作ろうと思い、CHERRY軸を触ってみたのですが自分にはしっくりこなかったので別のものを探していました。その中で、80〜90年代に生産された多くのキーボードに使用されていたAlps軸が気になりました。特にサーモン(ピンク)軸が気になったので、サーモン軸を使っている Apple Extended Keyboard をオークションで購入し、解体してキースイッチを取り出し自作してみました。

気に入っているポイント

タクティカルキーボードの少しだけカチッとくる、この独特で軽めな押し心地がいいですね。Alps軸の古代パーツ感も気に入っています。 

@ragi256

f:id:tokunarigyozadaisuki:20180814150453j:plain

キーボード概要

名称:Helix
スイッチ:Kailhロープロファイル 赤軸(45g)とKailhロープロファイル 茶軸(45g)の併用
レイアウト:6x5格子配列スプリットキーボード

なぜ今のキーボードにしたのか

昔から特殊な形状のキーボードが好きで、Dactylキーボードを作っているブログ記事を見たときに自分でもDactylを作ってみたいと思いました。しかし、はんだ付けもやったことがない自分には難しそうだったので、一旦難易度を下げて簡単なキーボードを作ることにしました。Helixに決めた理由は、以前から一度試してみたかったKailhのロープロファイルスイッチが使えるからです。

気に入っているポイント

親指を使うキーだけ押し心地を変えたかったので、親指周りだけを茶軸にして他は赤軸にしました。キーキャップは、デフォルトの刻印セットに加えて無地の白と黒を見た目で覚えやすいように配置しています。スプリットキーボードなので、椅子の肘掛けに肘先を置いて使えるのもいいですね。

@uzzu

f:id:tokunarigyozadaisuki:20180814171034p:plain

キーボード概要

名称:NIZ keyboard Plum 75 EC Keyboard
スイッチ:静電容量無接点方式(35g)
レイアウト:75%キーボード

なぜ今のキーボードにしたのか

社会人になってから、主にRealforce 86Uを8年くらい使っていました。特に困ってもいなかったのですが気分転換をしたくなり、Realforceの打鍵感が好きだったのでそこはあまり変わらず、それでいて、いじりやすくて不要なキーが無いコンパクトなものを探していて、NIZ keyboardにしました。他の皆さんのキーボードとは違い、組まれた状態で売られているので自作とは言えないですね……。

気に入っているポイント

75%である事(ファンクションキーは欲しいけど十字キーとハードウェアキーと操作キーはいらない)、さらに右側の特殊キーを十字キーに変えられるというのが、まさに自分の需要に合っていて気に入っています。加えて、静電容量無接点スイッチなのにCherry MX互換のキーキャップが採用されているので、他の自作キーボードと同様にキーキャップも変えられますし、バネを付けることでキーの重さが変えられるんです。Realforceの偏荷重モデルの重さを参考にしつつ、気になる所の重さを調整して自己最適化しています。今後、キーキャップは変更していきたいと思っています。

@ayemos

f:id:tokunarigyozadaisuki:20180814150747j:plain

キーボード概要

名称:WASD Keyboard
スイッチ:Cherry MX 青軸(50g)
レイアウト:60%キーボード

なぜ今のキーボードにしたのか

大学生の時HHKBを使っていたのですが、ErgoDoxで組まれた自作キーボードを見たとき、自分好みのキーキャップに変えられるものがほしいと思いました。

気に入っているポイント

深夜に酔った勢いで作ったので気に入ってるとかこだわりとかはないですね。漢字を使ったキーキャップデザインを自作しました。

最後に

いかがでしたでしょうか。以前はHHKBシリーズを使っていて、そこから自作キーボードの門を叩いたという社員が多いように感じました。自分にとって最適なキーボードを求めて細かいところから自作する人、見た目の可愛さを求めて工夫する人と、こだわりは様々。インタビューしていてとても楽しかったです。徐々に詳しくなってきましたよ! Gherkinってキーボードをつくってみたいと思っています。

今回紹介しきれなかった社員の自作キーボードについてはまた次回。お楽しみに! 

リリース間近の新規事業「クックパッドマート」の立ち上げの話

こんにちは、買物事業部のデザイナー兼エンジニアの長野です。

現在買物事業部では、クックパッドマートという新規サービスの開発を進めています。この夏にいよいよリリースを予定しており、先日 プレスリリース を発表しました。

クックパッドマートは、今年の1月に私を含めて3名の小さなチームでサービスづくりを開始しました(8/13現在:10名)。チーム発足から半年をかけて様々な検証を行い、サービスを形にしてきたので、本記事ではそのプロセスの一部を下記の流れでご紹介したいと思います。

  1. クックパッドマートとは
  2. サービスが解決したい課題
  3. サービスが提供する価値
  4. 価値仮説に至るまでのプロセス

1. クックパッドマートとは

クックパッドマートは、料理が楽しみになるような食材を、スマホアプリから簡単に注文することができる、生鮮食品のECサービスです。

地域の精肉店や鮮魚店、野菜農家、ベーカリーなどの「こだわり食材」をアプリでまとめて注文できます。 「焼きたてパン」や「朝採れ野菜」などの新鮮な食材を、販売店から集荷した当日に受け取ることができ、1品からでも送料は無料。毎回必要な分だけを手軽に購入することができます。

商品の受け取りは、地域の様々な店舗・施設等に設置された「受け取り場所」の中から好きな場所を選び、好きな時間に受け取ることが可能です。そのため、日中忙しくて買い物をする時間がない方でも、新鮮なこだわり食材を手軽に入手することができるサービスです。

ティザーサイトはこちら

2. サービスが解決したい課題

クックパッドマートは、私たちが普段の買い物に抱える下記のような課題を解決しようとしています。

おいしい食材を新鮮な状態で手に入れることの難しさ

世の中の大半の人は普段の食材はスーパーで購入することが多いと思います。何でも揃うスーパーはとても便利ですが、一方で、スーパーの品揃え次第で食生活が決まってしまうのも事実です。 街にはこだわりを持って厳選された食材を扱う専門店もありますが、多くの人がその存在を知らない、もしくは知っていてもそれらの店舗を回る手間や時間をかけられない、というのが実情だと思います。

まとめ買いせざるを得ない環境と仕組み

仕事をしていたり忙しい人ほど、普段から買い物にかけられる時間は少なく、週末に1週間分をまとめ買いする人も多いのではないでしょうか。 また、ECサービスを利用する場合も、配送コストをカバーするための「最低注文金額」が設定されていることがほとんどです。 そのため、たとえ新鮮な状態で購入したとしても、結果的に家庭の冷蔵庫で鮮度を落としてしまうのが現実です。

3. サービスが提供する価値

クックパッドマートは、これらの課題を解決するため、以下の3つの軸で価値を実現しようとしています。

提供する3つの価値
クックパッドマートが提供する3つの価値

品質が良いこと

おいしい食材は食卓を豊かにし、毎日の楽しみを増やすことができるというのは、あまり疑いようがないのではないでしょうか。 クックパッドマートは、こだわりを持っておいしい食材を届けたいと考えている販売店や生産者の方にご協力をいただき、自信を持っておすすめできる食材だけを商品として扱います。 また、おいしいものをおいしいうちに食べることも大切です。出荷当日に配送し、数日で使いきれる量だけを注文できるサービスとすることで、それを実現しようとしています。

調達コストが低いこと

クックパッドマートはアプリで手軽に注文でき、1品でも送料は無料。受け取りは自宅ではなく、近所の配送拠点を選択する形をとります。 使いきれる量の食材を、いつもの帰り道で好きな時間に受け取れることで、食材の調達にかかるコスト(時間や手間)を減らします。

調理コストが低いこと

献立を考えながら買い物をするのは、時間もかかり、毎日のこととなればストレスを感じる人も多いです。クックパッドマートは、実際の調理例を見ながら食材を注文でき、献立決めと買い物が一度に完結する体験を提供します。 また、新鮮な食材はシンプルな調理だけで十分においしいので、良い食材が手に入れば結果的に調理は楽になるとも考えています。

4. 価値仮説に至るまでのプロセス

上記の「サービスが提供する価値」の仮説に至るまでに繰り返してきた様々な検証のプロセスをご紹介します。

スプレッドシートによる買物代行テスト

チーム発足当初に描いていたサービスイメージは、買い物の手間やストレスを減らすことを目的とした、シンプルな買物代行サービスでした。 そこで、普段の買い物を誰かに代行してもらえることにどれだけの価値を感じられるのか、またそれを実現するにはどのような課題があるのかを検証するため、まずは実際にチームメンバーで社員の普段の買い物を代行するテストを実施することから、プロジェクトを始めました。

最初のテストで用意したシステムはごくシンプルです。Googleスプレッドシートで作った発注書と、全ての注文をまとめた買い出しリストの2つで、実装期間は約3日ほど。注文したい人は、発注書のテンプレートをコピーし、欲しい食材の注文数を記入後、チームのメーリングリストに共有する、というものでした。

スプレッドシートの発注書と買い出しリスト
実際に使用したスプレッドシートの発注書と買い出しリスト

目的の価値検証ができる最も簡単な方法を選んだことで、チーム発足後わずか2週間で最初のテストを実施することができました。

結果どうだったか

初日だけで20名以上の社員から注文が入り、レジ袋20袋以上の量の食材をチームメンバー3人で買い出しに行きました。

このようなテストを1回実施してみただけでも、本当に山ほどの課題とサービス改善のヒントが得られました。その一部を以下に紹介します。(実際はこの形式のテストを数回実施しました)

  • 普段買っているスーパーの商品と同等のものを届けても、あまり有り難みを感じてもらえない
  • 1:1の買い物代行はスケールが難しい上に、コストが見合わなそう
  • 複数店舗での買い出しと、それらの店舗を巡る配送を分ける仕組みは良さそう
  • まとめて買い出した後の個別仕分けは恐ろしく大変で効率化が必須
  • 注文時に量感がわからず、持ち帰れないほどの量を頼んでしまう人がいる
  • 鮮魚はその日のオススメをおまかせで選び、食べ方まで提案したところ、とても評判が良かった
  • おいしい!と実感できた人から、自然発生的にレビューが届いた(チームのSlackチャンネルに写真と感想が寄せられた)

買い出しテスト後の仕分けの様子
最初の買い出し後の仕分けの様子(カオスです。。。)

このテストを経て、

  • おいしさに自信を持っておすすめできる商品だけを届ける
  • 1:1の買い物代行ではなく、1:Nのルート配送の仕組みを構築する
  • おいしい食べ方の提案まで含めた、買っただけで終わらない買い物体験を作る

といったサービスのコンセプトが明確になりました。

プロトタイプアプリによるテスト

上記のスプレッドシートによるテストは単発で行ったため、使う社員も少しイベントごとのような意識になってしまうという課題がありました。そこで、

  • 定常的に注文を受け付けられる仕組みの構築
  • 習慣的に使ってもらうための課題の洗い出し

を次の検証のステップとしました。

習慣的に利用してもらうために、スプレッドシートを手動で管理する形式を卒業し、簡単なWebアプリケーションを構築しました。この時も、機能は最低限に絞り、デザインも凝り過ぎずに、最短でテストを開始できる方法を意識して開発を進めました。アプリケーションの実装は約1週間ほど、コンテンツの準備などを含めるとリリースまで2週間ほどの期間でテストを開始しました。

Webアプリケーションの画面
実際のWebアプリケーションの画面

結果どうだったか

このタイミングから、社員はいつでもクックパッドマートで食材を注文できるようになり、毎週火・木の2回、注文された食材をオフィスに配送するテストが今現在も続けられています。

日常的に配送が行われることで、利用する社員も普段の買い物の手段の一つとしてクックパッドマートを使ってくれるようになりました。初回注文からのリピート率は約7割で、ほぼ毎回注文をしてくれるヘビーユーザーも出てきています。日常使いするほど、アンケートで厳しめのご意見をもらえることも多く、サービス改善に必要なフィードバックを常に得られる状況を作ることができました。

また、定常的に配送オペレーションを回すことで、配送の度にオペレーション上の新たな課題が見つかり、日々改善が進められています。社内テストを繰り返すことによって磨き上げが進んだオペレーションの例をいくつかご紹介します。

  • チームメンバー自ら買い出しに出向いていたものが、徐々に社外に委託することができるようになり、一部では配送員が直接店舗から商品を受け取る仕組みもできてきた
  • 商品に貼り付けるラベルに記載する情報の改善により、仕分けミスや受け取り間違いが起こりにくいシステム構築が進んだ
  • 当初ユーザーの不安要素に多く上がった保冷の問題も、配送時に常に温度計測を行い、検証を繰り返すことにより、食材に適した温度を維持しての配送が実現できてきた

チームMTGの様子
オペレーション改善を行うチームの配送振り返りMTGの様子

配送オペレーションのように複雑性の高いものは、トライを繰り返せる環境が常にあることが改善スピードに直結します。常時社員から注文が入る状態を作れていることで、配送の仕組みもどんどんブラッシュアップされ、サービス全体としての品質向上を進めることができています。

社外ユーザーテスト

上記の社内テストを日常的に実施するのと並行して、社外のターゲットユーザー層の方にご協力いただくユーザーテストも繰り返し実施してきました。 目的は、社員としてのバイアスが無い、より一般的なユーザーの率直な反応をみることです。

ユーザーテストは、毎回下記のような形式で実施しています。

  • ターゲットに近いユーザーのリクルーティング
  • 社内テストで運用中のプロトタイプアプリを使ってもらう
  • 参加できるチームメンバー全員、別室でユーザーテストの様子を観察する(参加できなかったメンバーは録画でキャッチアップする)
  • チーム全員で分析・振り返りをする

インタビュー観察部屋の様子
インタビュー観察部屋の様子

インタビュー後の分析の様子
インタビュー後の分析の様子

結果どうだったか

社内テストで使っているシステムと同じものを見せた場合でも、前提知識やバックグラウンドが異なる社外ユーザーからは、やはり社員とは異なる反応がたくさん得られました。その例をいくつかご紹介すると、

  • オフィス配送のイメージが持てない環境にいるユーザーも多い

    • 比較的近距離に住む社員が多いクックパッドに比べ、通勤時間が長く、持ち帰りがより現実的でない
    • 勤務先がオフィスビルとは限らず、小規模な店舗勤務など、配送拠点になるイメージが持てない
    • 会社の雰囲気的に買い物をしている姿を他の人に見られたくないという感覚の人も多い
  • 商品の品質(おいしさ)がアプリ上では判断できない

    • クックパッド社員は背景知識やクチコミにより初回注文のハードルがそこまで高くなかったが、一般ユーザーがサービスの品質を信頼して初めて注文するハードルはその何倍も高そう

このような結果はメンバー全員で受け止め、オフィスを配送先のメインとして考えていた方針を転換したり、おいしさを伝えるための方法をアプリ内に留まらずに広くアイデア出しをして検討したりと、プロジェクト全体の方向性修正に役立てています。

モバイルアプリへの移行

このように様々な検証を経て、徐々に社外リリースの方向性や価値仮説が定まってきたところで、これまでの知見を盛り込んだリリース版iOSアプリの開発を開始しました。

これまでのプロトタイプアプリのコードやデザインは捨てて、システム設計からすべてリニューアルする形式で開発を進めています。テストと改善をスピード感を持って繰り返してきたプロトタイプアプリは、どうしても不要なコードや現在の要件に合わない設計が残っているので、リリース要件が固まったこのタイミングで刷新する判断をしました。

今現在は社内テストも全てリリース版のiOSアプリに移行しており、社外リリースに向けた磨き込みを進めています。

まとめ

このように、ミニマムなテストで価値検証を繰り返すプロセスを愚直に続け、ようやく社外にリリースできる形のサービスとしてまとまってきたのが今です。

サービスの登場人物が多く(注文ユーザー・販売店・配送員・受け取り拠点)、食材というリアルなモノが介在する複雑性の高いサービスなので、実際にテストを回しながら品質を上げていくプロセスの有用性を強く実感しています。 またこのようなプロセスは、インターネット上にとどまらない買い物体験全体をデザインしている感覚が強く、サービス開発者としてとても刺激的で面白いです。

社外リリース後も、まだまだ価値検証と改善のプロセスは続きます。もしあなたの街にクックパッドマートの受け取り場所ができたら、ぜひ利用して、フィードバックをください。(東京都渋谷区・目黒区・世田谷区の一部エリアから順次リリース予定)

また、この記事を通して、クックパッドマートのサービス開発にご興味を持っていただけた方がいらっしゃいましたら、ぜひ一緒にサービスを作りましょう! エンジニア・デザイナーはもちろん、様々な職種で、一緒にサービスを作り上げる仲間を募集しています(募集要項: エンジニアオープンポジション)。ご応募おまちしております。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/