GitLab Duoで自動リファクタリング #GitLab #GitLabDuo #AI

はじめに

GitLab Duoはソフトウェア開発ライフサイクル全体でAIを活用できる枠組みです。
GitLab Duoには様々な機能(一覧)が含まれています。今回はAIにリファクタリングしてもらう機能を試してみましょう!

チャレンジ内容

以前作成した弁当注文アプリに対してGitLab Duoを使ってリファクタリングしてみます。
結果を確認して、よくできている点や修正が必要な点を評価していきます。
なお、/testsによるUT(自動テスト)の生成とは異なり、本機能はGitHub Copilotのスラッシュコマンドには存在しません。(自前のプロンプトを書いてコードを改善することはできます。)

チャレンジ結果

リファクタリングしてもらう機能の使い方は非常にシンプルです。VS Code上でリファクタリングしたいコードを選択し、GitLab Duo Chatで/refactorと入力するだけです。必要に応じて/refactor XXXを考慮/refactor XXX形式に変更などと指示することもできます。他のプログラミング言語への変換も可能です。早速実行してみます。

相変わらず英語ですが、概要の後にリファクタリング結果が出力されています。その後ろにはキーとなる改善点が出力されているのですが、文字数制限に達したようで切れてしまっています。いつものように続きと指示してみましょう。

なぜかリファクタリング結果が再出力されてしまいましたが、今度は切れずに最後まで出力されました。

ザッと見比べたところ2回目の方がより深くリファクタリングされているようなので、こちらを詳しく評価していくことにします。

元のコード

import React, { useCallback, useState } from 'react';
import { mkConfig, generateCsv, download } from 'export-to-csv';

import awsExports from './aws-exports';
import { Amplify } from 'aws-amplify';
import { generateClient } from 'aws-amplify/api';
import { createBento } from './graphql/mutations';
import { CreateBentoInput } from './API';

import I_hamburger from './image/28206674_s.jpg';
import I_karaage from './image/28796399_s.jpg';
import I_makunouchi from './image/28927123_s.jpg';
import I_hirekatsu from './image/28939604_s.jpg';
import { listBentos } from './graphql/queries';

Amplify.configure(awsExports);

// v0 by Vercel.
// https://v0.dev/t/w1UzOcXfEI5
export default function Main() {
  type Bento = {
    id: number;
    name: string;
    imageUrl: string;
  };
  const bentoList: Bento[] = [
    { id: 1, name: 'ハンバーグ', imageUrl: I_hamburger },
    { id: 2, name: '唐揚げ', imageUrl: I_karaage },
    { id: 3, name: '幕の内', imageUrl: I_makunouchi },
    { id: 4, name: 'ヒレカツ', imageUrl: I_hirekatsu },
  ];

  const [employeeNumber, setEmployeeNumber] = useState('');

  const onClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
    async (e) => {
      if (employeeNumber === '') {
        alert('社員番号が入力されていません。');
        return;
      }

      const bentoId = parseInt(e.currentTarget.dataset.bentoId ?? '');
      const answer = confirm(bentoList.find((bento) => bento.id === bentoId)?.name + ' を注文しますか?');
      if (!answer) {
        return;
      }

      // 注文処理。
      const input: CreateBentoInput = {
        date: Date.now(),
        employeeNumber: employeeNumber,
        bentoId: bentoId,
      };
      const response = await generateClient().graphql({ query: createBento, variables: { input: input } });
      if (response.errors) {
        throw new Error();
      }

      alert('注文しました。');
    },
    [employeeNumber],
  );

  const downloadCsv: React.MouseEventHandler<HTMLButtonElement> = async () => {
    const csvConfig = mkConfig({
      useKeysAsHeaders: true,
      filename: 'orders',
    });

    const response = await generateClient().graphql({ query: listBentos });
    if (response.errors) {
      throw new Error();
    }

    type CsvRecord = {
      date: string;
      employeeNumber: string;
      bentoId: number;
    };
    const records: CsvRecord[] = response.data.listBentos.items.map((item) => {
      return {
        date: new Date(item.date).toJSON(),
        employeeNumber: item.employeeNumber,
        bentoId: item.bentoId,
      };
    });

    const csv = generateCsv(csvConfig)(records);
    download(csvConfig)(csv);
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-12 space-y-4 md:py-24">
      <div className="grid gap-4 w-full max-w-sm p-4 sm:max-w-md">
        <div className="grid gap-2">
          <label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" htmlFor="employee-number">
            Employee number
          </label>
          <input
            className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
            id="employee-number"
            placeholder="Enter employee number"
            value={employeeNumber}
            onChange={(e) => setEmployeeNumber(e.target.value)}
          />
        </div>

        <div className="grid gap-2 mt-10">
          <label className="leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Choose your bento</label>
          <div className="grid gap-2 md:grid-cols-2">
            {bentoList.map((bento) => (
              <div key={bento.id} data-bento-id={bento.id} className="flex items-center gap-2 cursor-pointer mt-2 bg-stone-800" onClick={onClick}>
                <img alt={bento.name} className="aspect-square rounded-md object-cover" height="100" src={bento.imageUrl} width="100" />
                <span className="text-center text-base">{bento.name}</span>
              </div>
            ))}
          </div>
        </div>

        <button
          className="mt-20 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
          onClick={downloadCsv}>
          CSV output
        </button>
      </div>
    </div>
  );
}

1回目の出力結果

import React, { useCallback, useState } from 'react';
import { mkConfig, generateCsv, download } from 'export-to-csv';

import awsExports from './aws-exports';
import { Amplify } from 'aws-amplify';
import { generateClient } from 'aws-amplify/api';
import { createBento } from './graphql/mutations';
import { CreateBentoInput } from './API';
import { listBentos } from './graphql/queries';

// Bento images
import I_hamburger from './image/28206674_s.jpg';
import I_karaage from './image/28796399_s.jpg';
import I_makunouchi from './image/28927123_s.jpg';
import I_hirekatsu from './image/28939604_s.jpg';

Amplify.configure(awsExports);

// Types
type Bento = {
  id: number;
  name: string;
  imageUrl: string;
};

type BentoOrder = {
  date: string;
  employeeNumber: string;
  bentoId: number;
};

// Constants
const BENTO_LIST: Bento[] = [
  { id: 1, name: 'ハンバーグ', imageUrl: I_hamburger },
  { id: 2, name: '唐揚げ', imageUrl: I_karaage },
  { id: 3, name: '幕の内', imageUrl: I_makunouchi },
  { id: 4, name: 'ヒレカツ', imageUrl: I_hirekatsu },
];

const CSV_CONFIG = mkConfig({
  useKeysAsHeaders: true,
  filename: 'orders',
});

// Components
const BentoCard: React.FC<{
  bento: Bento;
  onClick: (bentoId: number) => void;
}> = ({ bento, onClick }) => (
  <div 
    data-bento-id={bento.id} 
    className="flex items-center gap-2 cursor-pointer mt-2 bg-stone-800" 
    onClick={() => onClick(bento.id)}
  >
    <img 
      alt={bento.name} 
      className="aspect-square rounded-md object-cover" 
      height="100" 
      src={bento.imageUrl} 
      width="100" 
    />
    <span className="text-center text-base">{bento.name}</span>
  </div>
);

// Main component
export default function Main() {
  const [employeeNumber, setEmployeeNumber] = useState('');
  const client = generateClient();

  const handleBentoSelection = useCallback(
    async (bentoId: number) => {
      if (!employeeNumber) {
        alert('社員番号が入力されていません。');
        return;
      }

      const selectedBento = BENTO_LIST.find((bento) => bento.id === bentoId);
      if (!selectedBento) {
        alert('選択された弁当が見つかりません。');
        return;
      }

      const confirmOrder = confirm(`${selectedBento.name} を注文しますか?`);
      if (!confirmOrder) {
        return;
      }

      try {
        // Create order
        const input: CreateBentoInput = {
          date: Date.now(),
          employeeNumber,
          bentoId,
        };
        
        const response = await client.graphql({ 
          query: createBento, 
          variables: { input } 
        });
        
        if (response.errors) {
          throw new Error('注文の処理中にエラーが発生しました。');
        }

        alert('注文しました。');
      } catch (error) {
        console.error('Order submission error:', error);
        alert('注文の処理中にエラーが発生しました。もう一度お試しください。');
      }
    },
    [employeeNumber]
  );

  const handleDownloadCsv = async () => {
    try {
      const response = await client.graphql({ query: listBentos });
      
      if (response.errors) {
        throw new Error('データの取得中にエラーが発生しました。');
      }

      const records: BentoOrder[] = response.data.listBentos.items.map((item) => ({
        date: new Date(item.date).toJSON(),
        employeeNumber: item.employeeNumber,
        bentoId: item.bentoId,
      }));

      const csv = generateCsv(CSV_CONFIG)(records);
      download(CSV_CONFIG)(csv);
    } catch (error) {
      console.error('CSV download error:', error);
      alert('CSVのダウンロード中にエラーが発生しました。もう一度お試しください。');
    }
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-12 space-y-4 md:py-24">
      <div className="grid gap-4 w-full max-w-sm p-4 sm:max-w-md">
        {/* Employee Number Input */}
        <div className="grid gap-2">
          <label 
            className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 
            htmlFor="employee-number"
          >
            Employee number
          </label>
          <input
            className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
            id="employee-number"
            placeholder="Enter employee number"
            value={employeeNumber}
            onChange={(e) => setEmployeeNumber(e.target.value)}
          />
        </div>

        {/* Bento Selection */}
        <div className="grid gap-2 mt-10">
          <label className="leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
            Choose your bento
          </label>
          <div className="grid gap-2 md:grid-cols-2">
            {BENTO_LIST.map((bento) => (
              <BentoCard 
                key={bento.id} 
                bento={bento} 
                onClick={handleBentoSelection} 
              />
            ))}
          </div>
        </div>

        {/* CSV Download Button */}
        <button
          className="mt-20 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
          onClick={handleDownloadCsv}
        >
          CSV output
        </button>
      </div>
    </div>
  );
}

2回目の出力結果:

import React, { useCallback, useState } from 'react';
import { mkConfig, generateCsv, download } from 'export-to-csv';

import awsExports from './aws-exports';
import { Amplify } from 'aws-amplify';
import { generateClient } from 'aws-amplify/api';
import { createBento } from './graphql/mutations';
import { CreateBentoInput } from './API';
import { listBentos } from './graphql/queries';

// Bento images
import I_hamburger from './image/28206674_s.jpg';
import I_karaage from './image/28796399_s.jpg';
import I_makunouchi from './image/28927123_s.jpg';
import I_hirekatsu from './image/28939604_s.jpg';

Amplify.configure(awsExports);

// Types
type Bento = {
  id: number;
  name: string;
  imageUrl: string;
};

type CsvRecord = {
  date: string;
  employeeNumber: string;
  bentoId: number;
};

// Constants
const BENTO_LIST: Bento[] = [
  { id: 1, name: 'ハンバーグ', imageUrl: I_hamburger },
  { id: 2, name: '唐揚げ', imageUrl: I_karaage },
  { id: 3, name: '幕の内', imageUrl: I_makunouchi },
  { id: 4, name: 'ヒレカツ', imageUrl: I_hirekatsu },
];

const CSV_CONFIG = mkConfig({
  useKeysAsHeaders: true,
  filename: 'orders',
});

// Custom hooks
const useGraphQLClient = () => {
  return generateClient();
};

// Components
const BentoCard: React.FC<{
  bento: Bento;
  onSelect: (bentoId: number) => void;
}> = ({ bento, onSelect }) => (
  <div 
    data-bento-id={bento.id} 
    className="flex items-center gap-2 cursor-pointer mt-2 bg-stone-800 hover:bg-stone-700 transition-colors rounded-md p-2" 
    onClick={() => onSelect(bento.id)}
  >
    <img 
      alt={bento.name} 
      className="aspect-square rounded-md object-cover" 
      height="100" 
      src={bento.imageUrl} 
      width="100" 
    />
    <span className="text-center text-base">{bento.name}</span>
  </div>
);

const EmployeeNumberInput: React.FC<{
  value: string;
  onChange: (value: string) => void;
}> = ({ value, onChange }) => (
  <div className="grid gap-2">
    <label 
      className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 
      htmlFor="employee-number"
    >
      Employee number
    </label>
    <input
      className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
      id="employee-number"
      placeholder="Enter employee number"
      value={value}
      onChange={(e) => onChange(e.target.value)}
      required
    />
  </div>
);

// Utility functions
const validateEmployeeNumber = (employeeNumber: string): boolean => {
  return employeeNumber.trim() !== '';
};

const findBentoById = (bentoId: number): Bento | undefined => {
  return BENTO_LIST.find((bento) => bento.id === bentoId);
};

// Main component
export default function Main() {
  const [employeeNumber, setEmployeeNumber] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const client = useGraphQLClient();

  const handleBentoSelection = useCallback(
    async (bentoId: number) => {
      if (!validateEmployeeNumber(employeeNumber)) {
        alert('社員番号が入力されていません。');
        return;
      }

      const selectedBento = findBentoById(bentoId);
      if (!selectedBento) {
        alert('選択された弁当が見つかりません。');
        return;
      }

      const confirmOrder = confirm(`${selectedBento.name} を注文しますか?`);
      if (!confirmOrder) {
        return;
      }

      setIsLoading(true);
      
      try {
        const input: CreateBentoInput = {
          date: Date.now(),
          employeeNumber: employeeNumber.trim(),
          bentoId,
        };
        
        const response = await client.graphql({ 
          query: createBento, 
          variables: { input } 
        });
        
        if (response.errors) {
          throw new Error('注文の処理中にエラーが発生しました。');
        }

        alert('注文しました。');
        setEmployeeNumber(''); // Clear form after successful order
      } catch (error) {
        console.error('Order submission error:', error);
        alert('注文の処理中にエラーが発生しました。もう一度お試しください。');
      } finally {
        setIsLoading(false);
      }
    },
    [employeeNumber, client]
  );

  const handleDownloadCsv = useCallback(async () => {
    setIsLoading(true);
    
    try {
      const response = await client.graphql({ query: listBentos });
      
      if (response.errors) {
        throw new Error('データの取得中にエラーが発生しました。');
      }

      const records: CsvRecord[] = response.data.listBentos.items.map((item) => ({
        date: new Date(item.date).toJSON(),
        employeeNumber: item.employeeNumber,
        bentoId: item.bentoId,
      }));

      const csv = generateCsv(CSV_CONFIG)(records);
      download(CSV_CONFIG)(csv);
    } catch (error) {
      console.error('CSV download error:', error);
      alert('CSVのダウンロード中にエラーが発生しました。もう一度お試しください。');
    } finally {
      setIsLoading(false);
    }
  }, [client]);

  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-12 space-y-4 md:py-24">
      <div className="grid gap-4 w-full max-w-sm p-4 sm:max-w-md">
        {/* Employee Number Input */}
        <EmployeeNumberInput 
          value={employeeNumber}
          onChange={setEmployeeNumber}
        />

        {/* Bento Selection */}
        <div className="grid gap-2 mt-10">
          <label className="leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
            Choose your bento
          </label>
          <div className="grid gap-2 md:grid-cols-2">
            {BENTO_LIST.map((bento) => (
              <BentoCard 
                key={bento.id} 
                bento={bento} 
                onSelect={handleBentoSelection} 
              />
            ))}
          </div>
        </div>

        {/* CSV Download Button */}
        <button
          className="mt-20 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
          onClick={handleDownloadCsv}
          disabled={isLoading}
        >
          {isLoading ? 'Processing...' : 'CSV output'}
        </button>
      </div>
    </div>
  );
}

結果の評価

importの整理

VS Codeに自動で記述させたimportの並び順が整理されました。有り難いですが、整理が必要なのであればESLintで確実に対応した方がよいでしょう。

コードブロックごとのコメント

各コードブロックの先頭に簡単なコメントが追加されました。英語です。多少ですが、コードを読む際の助けになりそうです。

v0で生成された旨のコメント

Mainコンポーネントはv0で生成してあり、その旨がコメントしてあったのですが、そのコメントは削除されてしまいました。手動で復活させるのがよいでしょう。

型定義の切り出し

コンポーネント内で定義してあった型がコンポーネントの外に切り出されました。型の再利用やコンポーネント本体の簡素化という観点では切り出すのも悪くないですが、カプセル化・隠蔽・凝集度などの観点からは使う箇所の近くで定義しておいた方がよいかもしれません。

定数名の変更

bentoListBENTO_LISTに変更されました。今回はモックデータで固定の一覧なのでこの名前の方がよいかもしれません。本来はAPIから取得したデータを保持する変数なので、元の名前の方がよいかもしれません。

設定の切り出し

設定値CSV_CONFIGがコンポーネントの外に切り出されました。CSV関連は別ファイルに切り出すと責務の明確化・再利用の容易化・本ファイルの簡素化につながります。そのための一歩として、まずはこの設定がコンポーネントの外に切り出されたのはよいステップです。必要に応じて続きでまたはいずれリファクタリングを進めればよさそうです。

GraphQLクライアント取得処理のカスタムフック化

GraphQLクライアントを生成するgenerateClientの呼び出しがuseGraphQLClient内に切り出されました。これで、クライアントの生成処理をカスタマイズする際に手を入れやすくなりました。この部分も、必要に応じて別ファイルに切り出すとさらによくなりそうです。

コンポーネントの切り出し

Mainコンポーネントの中に直接記述していた要素のうち、意味のある固まりがコンポーネントとして切り出されました。特にReact初心者が書いたコードやv0で生成したコードなどはコンポーネントの粒度が荒すぎることが多いので、このような改善は品質向上だけでなく学習目的でも助かります。

BentoCardhover:bg-stone-700 transition-colors rounded-md p-2というclassが追加されて見た目が変わってしまいました。EmployeeNumberInputinput要素にはrequired属性が追加されてしまいました。生成結果のレビューや動作の確認は必須ですが、ちょっとした変化は見落としてしまうかもしれません。コンポーネントのスナップショットテストがあると意図しない差分を確実に検出できます。

ユーティリティ関数の切り出し

コンポーネント内で定義してあった関数がコンポーネント外に切り出されました。これで、再利用やさらなるリファクタリングが行いやすくなりました。バリデーションや弁当データの処理などは、今後モデルを導入してそちらに移動するとよいかもしれません。

弁当選択時の処理

弁当選択時のハンドラ関数名がonClickからhandleBentoSelectionに改善されました。Reactでの標準的な名前の付け方に従っていて、よいです。

BentoCardpropsBentoを受け取るようになったため、BentoCardのイベントハンドラ内では弁当のIDが既知になりました。そのため、選択された弁当のIDをdata属性から取得していた処理は廃止され、asyncの関数に直接弁当IDを渡す設計に変更されています。data属性は便利ですが、複雑化やバグの原因になることがあるため、今回の実装の方がよいでしょう。

弁当検索処理は名前変更と処理の切り出しが行われていますが、ロジックは元のままです。asyncの関数で弁当のIDではなく弁当自体を受け取るようにすれば、この検索処理は不要になるはずです。あと一歩でした。

次は、選択された弁当が弁当の一覧から見つかったかどうかの確認です。この確認処理は元々は存在していませんでした。存在する弁当の一覧から選択させているので「見つからない」ということは起こりえず、無駄な処理と言えます。何らかの問題が起こったときのためディフェンシブにガードしておくという考え方もできますが、不要なコードによってメンテナンス性が下がることとのトレードオフになります。ここでは、先述のように弁当IDではなく弁当自体を受け取っていれば、そもそも検索処理自体が不要になります。

確認用のメッセージはテンプレートリテラルを使った方式に変更されました。元の処理では検索と確認が一体になっていたということもありますが、今回の変更で明らかに分かりやすくなっています。なお、テンプレートリテラルはESLintで使用を強制することもできます。また、確認結果を保持する変数の名前がanswerからconfirmOrderに変更され、より具体的な内容を表すように改善されました。

注文のメイン処理を見ていきましょう。まず、全体がsetIsLoading(true)setIsLoading(false)で挟まれました。このstateは新たに導入されたものですが、このstateでの制御については後ほど詳しく確認することにします。次に、全体がtry-catchで囲まれています。GraphQLのAPIを呼び出す部分なので、このようにtryで囲んでおいた方が安全でしょう。それから、API呼び出しの結果がエラーだった(response.errors)場合は単にthrow new Error()しているだけだったのですが、もう少しマシなエラー表示に変更されました。

続けて、細かい部分を見ていきます。employeeNumberは入力値をそのまま採用していたのですが、trimした結果を使うようになってしまいました。そのような要件であればこれでよいですが、そうでなければ不要な処理を追加されてバグが混入したということになります。自動テスト(UT)があれば、意図しない挙動の変化を検出できる可能性が高まります。

bentoId: bentoIdbentoIdというショートハンドに変更されました。この後に出てくる{ input: input }{ input }に変更されています。これについては、単にコーディングスタイルの問題であり、どちらがよいとは言いきれません。必要であればESLintでどちらかを強制できます。

CSV出力処理

名前の変更やtry-catchの導入など、弁当選択時の処理と同様の改善が加えられています。それらは詳細を割愛します。

アロー関数でブロックボディのreturnを使っていた箇所が式ボディのreturnに変更されました。コーディングスタイルの問題とも言えますが、後者の方がスッキリしています。これも、ESLintで強制できます。

コンポーネント関数のreturn部分

要素の固まりが子コンポーネントとして切り出されました。見やすく、分かりやすくなり、メンテナンス性が向上しています。

loadingというstate

今回のリファクタリングでloadingというstateが新たに導入されています。

この値が参照されているのはCSV出力ボタンの制御だけです。ロード(処理)中はCSV出力を実行させないように変更されたようです。

この値を制御しているのは2箇所です。1つは、弁当購入時のAPI呼び出し前後(先述の箇所)です。もう1つは、CSVダウンロード処理の開始時にtrueにして終了時にfalseにしています。

つまり、API呼び出し中やCSV出力中はCSV出力ボタンを押させないようになっています。前者は、API呼び出し中でもCSV出力をリクエストできてよい気がするので、不要なガードによってUXが低下してしまっているように思えます。後者は連打防止などに有効な実装パターンなので、これで問題ないでしょう。

自動テスト(UT)

先日の記事でGitLab Duoを使ってUTを自動生成し、人力で微調整してあります。今回のリファクタリングによって一部のUTが失敗するようになっているので、詳細を確認しましょう。

mkConfigが呼ばれるはずだが呼ばれていない」という内容です。先述の通り、mkConfigの呼び出しはコンポーネント内にあったものがコンポーネント外に切り出されています。そのため、レンダリング時ではなくimport時に呼び出されるようになりました。UTではテストケースの実行前(beforeEach)にclearAllMocksしており、テストケース内のレンダリング時にはmkConfigは呼ばれないため、テストが失敗するようになりました。

「レンダリング時にmkConfigが呼ばれるはず」というアサーション自体が不要になったため、当該箇所を削除(ひとまずコメントアウト)します。これで、テストが通るようになりました。

まとめ

GitLab Duoを使った自動リファクタリング全体の振り返りです。

  • 多くの優れた改善が瞬時に出力された。
  • 挙動が変わってしまう変更も含まれており、「お節介な改善」によるバグ混入の危険がある。
    • 手動でもリファクタリングする前にUTが存在することが推奨されますが、自動リファクタリングの場合も同様です。
    • 処理だけでなくレンダリング結果が変更されることもあります。コンポーネントの切り出しなどで元と同じ内容になっているかのレビューが難しい場合もあるため、コンポーネントのスナップショットテストが推奨されます。
  • 大量の変更が発生するため・・・
    • レビュー・評価できることが必須。
      • 自動テストでカバーできていないバグやリスクが混入しているかもしれません。
    • 元に戻せることが必要。
      • 通常Gitなどで管理されていると思いますが、AIによるリファクタリングの際も同様に必須です。
  • いくつかの改善はESLintで強制できる内容だった。
    • 自動リファクタリングによって逆に壊されてしまうこともあるため、ESLintなどのツールで対応できるものはなるべくそうしましょう。コーディング中から自動で確実に対応できます。
  • 改善の最初の一歩としてはよいが、あと一歩でさらに改善できる内容もある。
    • 今回は検証していませんが、/refactorを繰り返し再実行するとよいかもしれません。そもそもリファクタリングは繰り返し継続的に行うべきものです。

GitLab Duoでの自動リファクタリングは、留意点はあるものの、品質の改善やよいコーディングの学習にとても役立つことが分かりました。UTの自動作成などと合わせて、ソフトウェア開発の様々なフェーズを自動化し、楽しく高品質なプログラミングを実践していきましょう。既にGitLabを利用中の方は、GitLab Duoの導入を考えてみるのもよさそうです。

Author

教育系エンジニア。開発やR&Dで最前線の技術を実践しつつ、後進の育成にも魂を燃やす。排出者は数千名。無類の子ども好きで、平日夕方は仕事を中抜けして近所の子どもと遊ぶ毎日。小学校でのプログラミング授業なども経験あり。

上村国慶の記事一覧

新規CTA