🤖

ChatGPT (GPT4) と TDD ライクなペアプロするとどうなるか試した

2023/03/19に公開

はじめに

ChatGPT などにプログラムコードを生成させることを考えたときに、現時点で現実的な使い方は主に以下の二通りであると考えた。

  • 毎回コード全体を出力させて前のものを捨てる
  • 初期バージョンのコードを出力させ、その後の変更などを人間で行う

いずれの場合も生成されたコードが期待どおりであるかを検証することが重要となるだろうと考えた。
したがって、人間がテストコードを記述し、それに適合するコードを出力させ、テストスイートに合格するかどうかで適切なコードが出力されたかを判断する。というシナリオが有用そうだと考えた。

場合によってはそのテストコード自体も機械に出力させ、人間がレビューするというシナリオもあるだろう。

今回は TODO MVC を対象に、段階的にテストコードを与えたときにどのようなやり取りになるかを確認した。

また、最初からテストスイート全体を渡してどのようになるかも確認したが、複数のエラーを同時に修正することができず、すべてのテストをパスするまでに至らなかった。

なお、TODO という情報を与えると TODO MVC のコードがそのまま出てくることがあったため、TODO という言葉を使わずにテストコードを書くように気をつけた。

以降では基本的にはChatGPTとのチャットログを以降に示すことになる。また、モデルが選択できるが GPT 4 を利用した。

また、GPT 3.5 のモデルでも試したが、個人的には使い物にならなかったと感じた。テストコードの中身を無視して、ひたすら TODO リストのコードを出力してきたり、要件にないローカルストレージに保存するコードを含めたりした。

やりとり

1. ベースとなるコードの実装

1.1. 筆者 -> ChatGPT

私とペアプログラミングをしましょう。

  • 私がテストコードを提示するのでそのテストケースをパスする最小限の実装をしてください
  • Vue.js のバージョン 3 と Typescript で実装を行ってください
  • コードのみを示してくださいコードの解説などは必要ありません
  • スタイリングは必要ありません
  • テストケースに失敗したらその内容をチャットで送信するので最小限のコードの修正をしてください
  • コードの変更後は変更後のコード全体を示してください
  • テストのコードには vitest を利用しています jest と互換がある API なので、vitest のことを知らなければ jest を使っていると思ってください
  • テストの記述には testing-library/vue を利用します

最初のテストコードは以下です。まずは以下のテストを通過する最小限の実装をしてください。

import { render, fireEvent } from '@testing-library/vue';
import App from './App.vue';

describe('Test for App', () => {
  let canvas: ReturnType<typeof render>;
  beforeEach(() => {
    canvas = render(App);
  });

  test('test', () => {
    expect(canvas).not.toBeNull();
  });
});

1.2. ChatGPT -> 筆者

以下が、指定されたテストケースをパスする最小限の実装です。

App.vue:

<template>
  <div></div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'App',
});
</script>

2. アイテムの追加

2.1. 筆者 -> ChatGPT

テストにパスしました。以下のテストケースを追加しました。
テストをパスする最小限の変更をしてください。

  test('入力したテキストに応じてアイテムができる', async () => {
    const textbox = await canvas.findByRole<HTMLInputElement>('textbox', {
      name: 'タイトル',
    });
    textbox.focus();
    userEvent.keyboard('item1');
    await waitFor(() => {
      expect(textbox.value).toBe('item1');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    await canvas.findByRole("listitem", {
      name: "item1"
    })
    await canvas.findByText("1 item left")
  });

2.2. ChatGPT -> 筆者

テストケースに合わせてApp.vueを以下のように変更しました。

App.vue:

// App.vue
<template>
  <div>
    <input
      v-model="title"
      @keyup.enter="addItem"
      type="text"
      placeholder="タイトル"
      aria-label="タイトル"
    />
    <ul>
      <li v-for="(item, index) in items" :key="index" aria-label="listitem">
        {{ item }}
      </li>
    </ul>
    <p>{{ items.length }} item{{ items.length !== 1 ? 's' : '' }} left</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';

export default defineComponent({
  name: 'App',
  setup() {
    const title = ref('');
    const items = ref<string[]>([]);

    function addItem() {
      if (title.value.trim()) {
        items.value.push(title.value.trim());
        title.value = '';
      }
    }

    return {
      title,
      items,
      addItem,
    };
  },
});
</script>

2.3. 筆者 -> ChatGPT

テストに失敗しました。

 FAIL  src/App.test.ts > Test for App > 入力したテキストに応じてアイテムができる
TestingLibraryElementError: Unable to find role="listitem" and name "item1"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li
          aria-label="listitem"
        >
          item1
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
    </div>
  </div>
</body>

2.4.ChatGPT -> 筆者

失敗したテストに対応するために、aria-labelledby を使ってリストアイテムの名前を設定します。

App.vue:

// App.vue
<template>
  <div>
    <input
      v-model="title"
      @keyup.enter="addItem"
      type="text"
      placeholder="タイトル"
      aria-label="タイトル"
    />
    <ul>
      <li
        v-for="(item, index) in items"
        :key="index"
        :aria-label="item"
        :aria-labelledby="'item-' + index"
      >
        {{ item }}
      </li>
    </ul>
    <p>{{ items.length }} item{{ items.length !== 1 ? 's' : '' }} left</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';

export default defineComponent({
  name: 'App',
  setup() {
    const title = ref('');
    const items = ref<string[]>([]);

    function addItem() {
      if (title.value.trim()) {
        items.value.push(title.value.trim());
        title.value = '';
      }
    }

    return {
      title,
      items,
      addItem,
    };
  },
});
</script>

3. アイテムの削除

3.1. 筆者 -> ChatGPT

テストにパスしました。以下のテストケースを追加しました。
テストをパスする最小限の変更をしてください。

  test('アイテムを削除できる', async () => {
    const textbox = await canvas.findByRole<HTMLInputElement>('textbox', {
      name: 'タイトル',
    });
    textbox.focus();
    userEvent.keyboard('item1');
    await waitFor(() => {
      expect(textbox.value).toBe('item1');
    });

    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    const item = await canvas.findByRole("listitem", {
      name: "item1"
    })
    const btn = await canvas.findByRole("button", {
      name: "item1 を 削除する",
    })
    userEvent.click(btn)
    await waitFor(() => {
      expect(canvas.queryByRole('listitem', { name: 'item1' })).toBeNull();
    });

  });

3.2. ChatGPT -> 筆者

テストケースに合わせて、アイテムを削除する機能を実装します。

App.vue:

// App.vue
<template>
  <div>
    <input
      v-model="title"
      @keyup.enter="addItem"
      type="text"
      placeholder="タイトル"
      aria-label="タイトル"
    />
    <ul>
      <li
        v-for="(item, index) in items"
        :key="index"
        :aria-label="item"
        :aria-labelledby="'item-' + index"
      >
        {{ item }}
        <button
          @click="removeItem(index)"
          aria-label="item1 を 削除する"
        >
          削除
        </button>
      </li>
    </ul>
    <p>{{ items.length }} item{{ items.length !== 1 ? 's' : '' }} left</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';

export default defineComponent({
  name: 'App',
  setup() {
    const title = ref('');
    const items = ref<string[]>([]);

    function addItem() {
      if (title.value.trim()) {
        items.value.push(title.value.trim());
        title.value = '';
      }
    }

    function removeItem(index: number) {
      items.value.splice(index, 1);
    }

    return {
      title,
      items,
      addItem,
      removeItem,
    };
  },
});
</script>

4. アイテムを完了できる

4.1. 筆者 -> ChatGPT

テストにパスしました。以下のテストケースを追加しました。
テストをパスする最小限の変更をしてください。

  test('アイテムを完了できる', async () => {
    const textbox = await canvas.findByRole<HTMLInputElement>('textbox', {
      name: 'タイトル',
    });
    textbox.focus();
    userEvent.keyboard('item1');
    await waitFor(() => {
      expect(textbox.value).toBe('item1');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    await canvas.findByRole("listitem", {
      name: "item1"
    })
    await canvas.findByText("1 item left")

    const checkbox = await canvas.findByRole("checkbox", {
      name: "item1 を完了する"
    })
    userEvent.click(checkbox)
    await canvas.findByText("0 items left")
  });

4.2. ChatGPT -> 筆者

テストケースに合わせて、アイテムの完了機能を実装します。

App.vue:

// App.vue
<template>
  <div>
    <input
      v-model="title"
      @keyup.enter="addItem"
      type="text"
      placeholder="タイトル"
      aria-label="タイトル"
    />
    <ul>
      <li
        v-for="(item, index) in items"
        :key="index"
        :aria-label="item"
        :aria-labelledby="'item-' + index"
      >
        <input
          type="checkbox"
          @change="toggleCompletion(index)"
          :aria-label="item + ' を完了する'"
        />
        {{ item }}
        <button
          @click="removeItem(index)"
          aria-label="item1 を 削除する"
        >
          削除
        </button>
      </li>
    </ul>
    <p>{{ itemsLeft }} item{{ itemsLeft !== 1 ? 's' : '' }} left</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue';

interface TodoItem {
  title: string;
  completed: boolean;
}

export default defineComponent({
  name: 'App',
  setup() {
    const title = ref('');
    const items = ref<TodoItem[]>([]);

    const itemsLeft = computed(() =>
      items.value.filter((item) => !item.completed).length
    );

    function addItem() {
      if (title.value.trim()) {
        items.value.push({ title: title.value.trim(), completed: false });
        title.value = '';
      }
    }

    function removeItem(index: number) {
      items.value.splice(index, 1);
    }

    function toggleCompletion(index: number) {
      items.value[index].completed = !items.value[index].completed;
    }

    return {
      title,
      items,
      addItem,
      removeItem,
      toggleCompletion,
      itemsLeft,
    };
  },
});
</script>

4.3. 筆者 -> ChatGPT

3つのテストケースで失敗しました。

 FAIL  src/App.test.ts > Test for App > 入力したテキストに応じてアイテムができる
TestingLibraryElementError: Unable to find role="listitem" and name "item1"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li
          aria-label="[object Object]"
          aria-labelledby="item-0"
        >
          <input
            aria-label="[object Object] を完了する"
            type="checkbox"
          />
           {
  "title": "item1",
  "completed": false
} 
          <button
            aria-label="item1 を 削除する"
          >
             削除 
          </button>
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
    </div>
  </div>
</body>

 FAIL  src/App.test.ts > Test for App > アイテムを削除できる
TestingLibraryElementError: Unable to find role="listitem" and name "item1"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li
          aria-label="[object Object]"
          aria-labelledby="item-0"
        >
          <input
            aria-label="[object Object] を完了する"
            type="checkbox"
          />
           {
  "title": "item1",
  "completed": false
} 
          <button
            aria-label="item1 を 削除する"
          >
             削除 
          </button>
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
    </div>
  </div>
</body>

FAIL  src/App.test.ts > Test for App > アイテムを完了できる
TestingLibraryElementError: Unable to find role="listitem" and name "item1"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li
          aria-label="[object Object]"
          aria-labelledby="item-0"
        >
          <input
            aria-label="[object Object] を完了する"
            type="checkbox"
          />
           {
  "title": "item1",
  "completed": false
} 
          <button
            aria-label="item1 を 削除する"
          >
             削除 
          </button>
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
    </div>
  </div>
</body>

4.4. ChatGPT -> 筆者

失敗したテストに対応するために、リストアイテムとチェックボックスの属性を修正し、表示テキストを修正します。

App.vue:

// App.vue
<template>
  <div>
    <input
      v-model="title"
      @keyup.enter="addItem"
      type="text"
      placeholder="タイトル"
      aria-label="タイトル"
    />
    <ul>
      <li
        v-for="(item, index) in items"
        :key="index"
        :aria-label="item.title"
        :aria-labelledby="'item-' + index"
      >
        <input
          type="checkbox"
          @change="toggleCompletion(index)"
          :aria-label="item.title + ' を完了する'"
        />
        {{ item.title }}
        <button
          @click="removeItem(index)"
          :aria-label="item.title + ' を 削除する'"
        >
          削除
        </button>
      </li>
    </ul>
    <p>{{ itemsLeft }} item{{ itemsLeft !== 1 ? 's' : '' }} left</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue';

interface TodoItem {
  title: string;
  completed: boolean;
}

export default defineComponent({
  name: 'App',
  setup() {
    const title = ref('');
    const items = ref<TodoItem[]>([]);

    const itemsLeft = computed(() =>
      items.value.filter((item) => !item.completed).length
    );

    function addItem() {
      if (title.value.trim()) {
        items.value.push({ title: title.value.trim(), completed: false });
        title.value = '';
      }
    }

    function removeItem(index: number) {
      items.value.splice(index, 1);
    }

    function toggleCompletion(index: number) {
      items.value[index].completed = !items.value[index].completed;
    }

    return {
      title,
      items,
      addItem,
      removeItem,
      toggleCompletion,
      itemsLeft,
    };
  },
});
</script>

5. 完了や未完了のみの表示に切り替えられる

5.1. 筆者 -> ChatGPT

テストにパスしました。以下のテストケースを追加しました。
テストをパスする最小限の変更をしてください。

  test('表示項目を切り替えられる', async () => {
    const textbox = await canvas.findByRole<HTMLInputElement>('textbox', {
      name: 'タイトル',
    });
    textbox.focus();
    userEvent.keyboard('item1');
    await waitFor(() => {
      expect(textbox.value).toBe('item1');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    userEvent.keyboard('item2');
    await waitFor(() => {
      expect(textbox.value).toBe('item2');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    expect(await canvas.findAllByRole("listitem")).toHaveLength(2)
    await canvas.findByText("2 items left")

    const checkbox = await canvas.findByRole("checkbox", {
      name: "item1 を完了する"
    })
    userEvent.click(checkbox)
    await canvas.findByText("1 item left")

    // Active
    userEvent.click(await canvas.findByRole("button", { name: "Active" }))
    await waitFor(async () => {
      expect(await canvas.findAllByRole("listitem")).toHaveLength(1)
      await canvas.findByRole("listitem", {
        name: "item2"
      })
    })
    // All
    userEvent.click(await canvas.findByRole("button", { name: "All" }))
    await waitFor(async () => {
      expect(await canvas.findAllByRole("listitem")).toHaveLength(2)
    })
    // Completed
    userEvent.click(await canvas.findByRole("button", { name: "Completed" }))
    await waitFor(async () => {
      expect(await canvas.findAllByRole("listitem")).toHaveLength(1)
      await canvas.findByRole("listitem", {
        name: "item1"
      })
    })
  });

5.2. ChatGPT -> 筆者

App.vue の変更を行いました。表示項目を切り替える機能を追加しました。

App.vue:

// App.vue
<template>
  <div>
    <input
      v-model="title"
      @keyup.enter="addItem"
      type="text"
      placeholder="タイトル"
      aria-label="タイトル"
    />
    <ul>
      <li
        v-for="(item, index) in filteredItems"
        :key="index"
        :aria-label="item.title"
        :aria-labelledby="'item-' + index"
      >
        <input
          type="checkbox"
          @change="toggleCompletion(index)"
          :aria-label="item.title + ' を完了する'"
          :checked="item.completed"
        />
        {{ item.title }}
        <button
          @click="removeItem(index)"
          :aria-label="item.title + ' を 削除する'"
        >
          削除
        </button>
      </li>
    </ul>
    <p>{{ itemsLeft }} item{{ itemsLeft !== 1 ? 's' : '' }} left</p>
    <button @click="setVisibility('all')" aria-label="All">All</button>
    <button @click="setVisibility('active')" aria-label="Active">Active</button>
    <button @click="setVisibility('completed')" aria-label="Completed">Completed</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue';

interface TodoItem {
  title: string;
  completed: boolean;
}

export default defineComponent({
  name: 'App',
  setup() {
    const title = ref('');
    const items = ref<TodoItem[]>([]);
    const visibility = ref<'all' | 'active' | 'completed'>('all');

    const itemsLeft = computed(() =>
      items.value.filter((item) => !item.completed).length
    );

    const filteredItems = computed(() => {
      switch (visibility.value) {
        case 'active':
          return items.value.filter((item) => !item.completed);
        case 'completed':
          return items.value.filter((item) => item.completed);
        case 'all':
        default:
          return items.value;
      }
    });

    function addItem() {
      if (title.value.trim()) {
        items.value.push({ title: title.value.trim(), completed: false });
        title.value = '';
      }
    }

    function removeItem(index: number) {
      items.value.splice(index, 1);
    }

    function toggleCompletion(index: number) {
      items.value[index].completed = !items.value[index].completed;
    }

    function setVisibility(newVisibility: 'all' | 'active' | 'completed') {
      visibility.value = newVisibility;
    }

    return {
      title,
      items,
      addItem,
      removeItem,
      toggleCompletion,
      itemsLeft,
      setVisibility,
      filteredItems,
    };
  },
});
</script>

6.

6.1. 筆者 -> ChatGPT

テストにパスしました。以下のテストケースを追加しました。
テストをパスする最小限の変更をしてください。

  test('完了済み項目を削除できる', async () => {
    const textbox = await canvas.findByRole<HTMLInputElement>('textbox', {
      name: 'タイトル',
    });
    textbox.focus();
    // add item1
    userEvent.keyboard('item1');
    await waitFor(() => {
      expect(textbox.value).toBe('item1');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    // add item2
    userEvent.keyboard('item2');
    await waitFor(() => {
      expect(textbox.value).toBe('item2');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    // add item3
    userEvent.keyboard('item3');
    await waitFor(() => {
      expect(textbox.value).toBe('item3');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    expect(await canvas.findAllByRole("listitem")).toHaveLength(3)
    await canvas.findByText("3 items left")

    userEvent.click(await canvas.findByRole("checkbox", {
      name: "item1 を完了する"
    }))
    userEvent.click(await canvas.findByRole("checkbox", {
      name: "item3 を完了する"
    }))
    await canvas.findByText("1 item left")
    // Completed
    userEvent.click(await canvas.findByRole("button", { name: "Completed" }))
    await waitFor(async () => {
      expect(await canvas.findAllByRole("listitem")).toHaveLength(2)
    })
    // Active
    userEvent.click(await canvas.findByRole("button", { name: "Active" }))
    await waitFor(async () => {
      expect(await canvas.findAllByRole("listitem")).toHaveLength(1)
    })
    // All
    userEvent.click(await canvas.findByRole("button", { name: "All" }))
    await waitFor(async () => {
      expect(await canvas.findAllByRole("listitem")).toHaveLength(3)
    })
    // Clear Complete
    userEvent.click(await canvas.findByRole("button", { name: "Clear Completed" }))
    await waitFor(async () => {
      expect(await canvas.findAllByRole("listitem")).toHaveLength(1)
      await canvas.findByRole("listitem", {name: "item2"})
    })
  })

6.2. ChatGPT -> 筆者

提示されたテストコードにパスするための最小限の実装を以下に示します。

<template>
  <!-- 中略 -->
  <button @click="setVisibility('all')" aria-label="All">All</button>
  <button @click="setVisibility('active')" aria-label="Active">Active</button>
  <button @click="setVisibility('completed')" aria-label="Completed">Completed</button>
  <button @click="clearCompleted" aria-label="Clear Completed">Clear Completed</button>
</template>

そして、<script> タグ内に以下の clearCompleted メソッドを追加します。

function clearCompleted() {
  items.value = items.value.filter((item) => !item.completed);
}

最終的に、return ステートメント内に clearCompleted を追加します。

return {
  title,
  items,
  addItem,
  removeItem,
  toggleCompletion,
  itemsLeft,
  setVisibility,
  filteredItems,
  clearCompleted,
};

7. アイテムのタイトルを変更できる

7.1. 筆者 -> ChatGPT

テストにパスしました。以下のテストケースを追加しました。
テストをパスする最小限の変更をしてください。

  test('項目のテキストを修正できる', async () => {
    const textbox = await canvas.findByRole<HTMLInputElement>('textbox', {
      name: 'タイトル',
    });
    textbox.focus();
    // add item1
    userEvent.keyboard('item1');
    await waitFor(() => {
      expect(textbox.value).toBe('item1');
    });
    userEvent.keyboard('{enter}');
    await waitFor(() => {
      expect(textbox.value).toBe('');
    });
    const item = await canvas.findByText("item1")
    userEvent.dblClick(item)
    await waitFor(async ()=> {
      // 編集のためのテキストボックスがフォーカスされている
     expect(await canvas.findByRole("textbox", {
        name: "item1 を編集"
      })).toBe(document.activeElement)
    })
    userEvent.keyboard("{backspace}{backspace}{backspace}{backspace}{backspace}new item{enter}")
    await canvas.findByRole("listitem", {
      name: "new item"
    })
  })

7.2. ChatGPT -> 筆者

以下の変更を加えることで、追加されたテストケースをパスできるようになります。

まず、<template> タグ内のリストアイテムを次のように更新します。

<li v-for="(item, index) in filteredItems" :key="index">
  <input
    v-if="item.editing"
    v-model="item.newTitle"
    @blur="updateItem(item)"
    @keydown.enter="updateItem(item)"
    :aria-label="`${item.title} を編集`"
    ref="editBox"
  />
  <template v-else>
    <input
      type="checkbox"
      :checked="item.completed"
      @change="toggleCompletion(item)"
      :aria-label="`${item.title} を完了する`"
    />
    <span @dblclick="editItem(item)">{{ item.title }}</span>
    <button @click="removeItem(item)" :aria-label="`${item.title} を 削除する`">
      削除
    </button>
  </template>
</li>

次に、<script> タグ内に以下の関数を追加します。

function editItem(item) {
  item.editing = true;
  item.newTitle = item.title;
  Vue.nextTick(() => {
    const editBox = document.querySelector(`[aria-label="${item.title} を編集"]`);
    editBox.focus();
  });
}

function updateItem(item) {
  item.title = item.newTitle.trim();
  item.editing = false;
}

最後に、return ステートメント内に editItem と updateItem を追加します。

return {
  title,
  items,
  addItem,
  removeItem,
  toggleCompletion,
  itemsLeft,
  setVisibility,
  filteredItems,
  clearCompleted,
  editItem,
  updateItem,
};

7.3. 筆者 -> ChatGPT

いくつか型エラーが出ています。現在のコードは以下のようになっています。

<template>
  <div>
    <input v-model="title" @keyup.enter="addItem" type="text" placeholder="タイトル" aria-label="タイトル" />
    <ul>
      <li v-for="(item, index) in filteredItems" :key="index">
        <input v-if="item.editing" v-model="item.newTitle" @blur="updateItem(item)" @keydown.enter="updateItem(item)"
          :aria-label="`${item.title} を編集`" ref="editBox" />
        <template v-else>
          <input type="checkbox" :checked="item.completed" @change="toggleCompletion(item)"
            :aria-label="`${item.title} を完了する`" />
          <span @dblclick="editItem(item)">{{ item.title }}</span>
          <button @click="removeItem(item)" :aria-label="`${item.title} を 削除する`">
            削除
          </button>
        </template>
      </li>

    </ul>
    <p>{{ itemsLeft }} item{{ itemsLeft !== 1 ? 's' : '' }} left</p>
    <button @click="setVisibility('all')" aria-label="All">All</button>
    <button @click="setVisibility('active')" aria-label="Active">Active</button>
    <button @click="setVisibility('completed')" aria-label="Completed">Completed</button>
    <button @click="clearCompleted" aria-label="Clear Completed">Clear Completed</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, computed,nextTick } from 'vue';

interface TodoItem {
  title: string;
  completed: boolean;
}

export default defineComponent({
  name: 'App',
  setup() {
    const title = ref('');
    const items = ref<TodoItem[]>([]);
    const visibility = ref<'all' | 'active' | 'completed'>('all');

    const itemsLeft = computed(() =>
      items.value.filter((item) => !item.completed).length
    );

    const filteredItems = computed(() => {
      switch (visibility.value) {
        case 'active':
          return items.value.filter((item) => !item.completed);
        case 'completed':
          return items.value.filter((item) => item.completed);
        case 'all':
        default:
          return items.value;
      }
    });

    function addItem() {
      if (title.value.trim()) {
        items.value.push({ title: title.value.trim(), completed: false });
        title.value = '';
      }
    }

    function removeItem(index: number) {
      items.value.splice(index, 1);
    }

    function toggleCompletion(index: number) {
      items.value[index].completed = !items.value[index].completed;
    }

    function setVisibility(newVisibility: 'all' | 'active' | 'completed') {
      visibility.value = newVisibility;
    }

    function clearCompleted() {
      items.value = items.value.filter((item) => !item.completed);
    } 
    function editItem(item) {
      item.editing = true;
      item.newTitle = item.title;
      nextTick(() => {
        const editBox = document.querySelector(`[aria-label="${item.title} を編集"]`);
        editBox.focus();
      });
    }

    function updateItem(item) {
      item.title = item.newTitle.trim();
      item.editing = false;
    }
    return {
      title,
      items,
      addItem,
      removeItem,
      toggleCompletion,
      itemsLeft,
      setVisibility,
      filteredItems, clearCompleted,
      editItem,
      updateItem,

    };
  },
});
</script>

7.4. ChatGPT -> 筆者

すみません。型定義が不足しているため、型エラーが発生しています。まず、TodoItem インターフェースを以下のように更新して、editing および newTitle プロパティを追加してください。

interface TodoItem {
  title: string;
  completed: boolean;
  editing?: boolean;
  newTitle?: string;
}

次に、removeItem および toggleCompletion 関数を以下のように更新して、引数の型を TodoItem に変更してください。

function removeItem(item: TodoItem) {
  items.value = items.value.filter(i => i !== item);
}

function toggleCompletion(item: TodoItem) {
  item.completed = !item.completed;
}

これで、コンパイルエラーが解消されるはずです。変更後のコードでテストを実行し、すべてのテストがパスすることを確認してください。

7.5. 筆者 -> ChatGPT

複数のテストケースで失敗しました。

 FAIL  src/App.test.ts > Test for App > 入力したテキストに応じてアイテムができる
TestingLibraryElementError: Unable to find role="listitem" and name "item1"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li>
          
          <input
            aria-label="item1 を完了する"
            type="checkbox"
          />
          <span>
            item1
          </span>
          <button
            aria-label="item1 を 削除する"
          >
             削除 
          </button>
          
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
      <button
        aria-label="All"
      >
        All
      </button>
      <button
        aria-label="Active"
      >
        Active
      </button>
      <button
        aria-label="Completed"
      >
        Completed
      </button>
      <button
        aria-label="Clear Completed"
      >
        Clear Completed
      </button>
    </div>
  </div>
</body>

 FAIL  src/App.test.ts > Test for App > アイテムを削除できる
TestingLibraryElementError: Unable to find role="listitem" and name "item1"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li>
          
          <input
            aria-label="item1 を完了する"
            type="checkbox"
          />
          <span>
            item1
          </span>
          <button
            aria-label="item1 を 削除する"
          >
             削除 
          </button>
          
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
      <button
        aria-label="All"
      >
        All
      </button>
      <button
        aria-label="Active"
      >
        Active
      </button>
      <button
        aria-label="Completed"
      >
        Completed
      </button>
      <button
        aria-label="Clear Completed"
      >
        Clear Completed
      </button>
    </div>
  </div>
</body>

 FAIL  src/App.test.ts > Test for App > アイテムを完了できる
TestingLibraryElementError: Unable to find role="listitem" and name "item1"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li>
          
          <input
            aria-label="item1 を完了する"
            type="checkbox"
          />
          <span>
            item1
          </span>
          <button
            aria-label="item1 を 削除する"
          >
             削除 
          </button>
          
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
      <button
        aria-label="All"
      >
        All
      </button>
      <button
        aria-label="Active"
      >
        Active
      </button>
      <button
        aria-label="Completed"
      >
        Completed
      </button>
      <button
        aria-label="Clear Completed"
      >
        Clear Completed
      </button>
    </div>
  </div>
</body>

 FAIL  src/App.test.ts > Test for App > 表示項目を切り替えられる
AssertionError: expected [ …(2) ] to have a length of 1 but got 2

 FAIL  src/App.test.ts > Test for App > 完了済み項目を削除できる
AssertionError: expected [ …(3) ] to have a length of 1 but got 3

 FAIL  src/App.test.ts > Test for App > 項目のテキストを修正できる
TestingLibraryElementError: Unable to find role="listitem" and name "new item"

Ignored nodes: comments, script, style
<body>
  <div>
    <div>
      <input
        aria-label="タイトル"
        placeholder="タイトル"
        type="text"
      />
      <ul>
        
        <li>
          
          <input
            aria-label="new item を完了する"
            type="checkbox"
          />
          <span>
            new item
          </span>
          <button
            aria-label="new item を 削除する"
          >
             削除 
          </button>
          
        </li>
        
      </ul>
      <p>
        1 item left
      </p>
      <button
        aria-label="All"
      >
        All
      </button>
      <button
        aria-label="Active"
      >
        Active
      </button>
      <button
        aria-label="Completed"
      >
        Completed
      </button>
      <button
        aria-label="Clear Completed"
      >
        Clear Completed
      </button>
    </div>
  </div>
</body>

7.6. ChatGPT -> 筆者

失敗したテストケースを修正するために、<li> タグに aria-labelledby を追加し、<span> タグに一意の ID を追加してください。これにより、テストがリストアイテムを正しく見つけることができます。

まず、v-for ディレクティブでインデックスを使用して一意の ID を生成します。

<li v-for="(item, index) in filteredItems" :key="index" :aria-labelledby="'item-title-' + index">
  <input v-if="item.editing" v-model="item.newTitle" @blur="updateItem(item)" @keydown.enter="updateItem(item)"
    :aria-label="`${item.title} を編集`" ref="editBox" />
  <template v-else>
    <input type="checkbox" :checked="item.completed" @change="toggleCompletion(item)"
      :aria-label="`${item.title} を完了する`" />
    <span :id="'item-title-' + index" @dblclick="editItem(item)">{{ item.title }}</span>
    <button @click="removeItem(item)" :aria-label="`${item.title} を 削除する`">
      削除
    </button>
  </template>
</li>

この変更により、失敗していたテストケースが正常に実行されるはずです。テストを再実行し、すべてのテストがパスすることを確認してください。

実装したもの

以下にある。

https://stackblitz.com/edit/vitejs-vite-zbytca?file=index.html

わかったことなど

  • 「はじめに」に書いたとおり、まるごとテストスイートを渡すとうまくいかなかった
    • 一つづつ渡すほうが上手に作れるかもしれない
  • テストのエラーを見て、誤ってるかもしれない箇所をあげてくれる
  • コード全体の整合性が取れなくなることがある
    • 型の指摘をした部分がそれ
    • うまく質問をして、整合性を取り直すように誘導する必要がある
  • テストに含める情報量を増やすことで実装の質が変わる
    • 最後の編集に関するテストケースのコメントで『// 編集のためのテキストボックスがフォーカスされている』と書いたが、あれがないと最後まで実装することができなかった。
    • テストの実装だけではフォーカスを移動させることが仕様であるとわからなかった
    • 今回は意図的に情報を絞った部分があるが、コメントなどを記述し、テストで確認したいことをちゃんと明示することで、実装の質が高くなる

おわりに

割と人間相手にやるコミュニケーションに近いと感じた。
それを意図したインターフェースなのだから、そりゃそうかといえばそうだが。

テストを記述して、実装を出力させることで、ここからリファクタリングをしたり、保守しやすくなると思う。なんなら、それも 機械に補助してもらうという選択肢もあるだろう。いずれにしても変更にやってプログラムが壊れたかどうかは(ある程度)テストで検知できるのだ。

また、人間から明確な命令を機械に渡すためのフォーマットとしてテストコードはそれなりに良いのではないかという考えが強くなった。
命令に適合しているかどうかを自動で判定できる点が特に気に入っている。
今回は testing-library を使ったが、これによって a11y に関するロールや属性を付与することを実装に矯正できる (適切に使われるかは別だが...) という点も気に入っている。人間が実装の方向性を強く示すことができる。
加えて、テスト結果を得て実装を改善できるという点はこれまでコンテキストを活かせるチャットというインターフェースにもあっていると思った。

テストコードを記述する過程で人間が実装の方向性や要件を検討し、理解を深め、対応する(初期)実装を機械から得ることで、開発全体の効率が上がるかもしれない。

これからどのような方向に動いていくかわからない点も多いが、うまく使うことで大きな成果を得ることができる道具であることは間違いないので、良い使い方を引き続き検討したい。

Discussion