お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。
投稿日:
開発::Webジャンル::調査Webサービス

スマホでWebを見てるときにキーボードがUIにかぶって操作しづらくなることがあるので、いくつかのサイトでどうなっているか調べてみた。

去年の9月にAndroid Edgeで調べた内容なので、今とは事情が異なるケースもある。Android Chromeでは起きなかったケースもあったので、Edge特有の挙動と思われる。

Twitter

ログイン画面

tw1.jpg

キーボードが入力欄やログインボタンにかぶる。

ミュート設定

tw3.jpg

入力欄が上にあるためかぶらない。

投稿画面

tw4.jpg

入力欄が上にあるためかぶらない。

YouTube

コメント画面

yt1.jpg
yt2.jpg

キーボードがボタンにかぶる。

通報モーダル

キーボードがボタンにかぶる。

yt3.jpg
yt4.jpg

GitHub

Issueのコメント画面

gh3.jpg
gh4.jpg

入力欄が丸ごとキーボードにかぶる。

一休

検索モーダル

ikyu1.jpg
ikyu2.jpg

宿泊予算の入力が丸っとかぶる。

じゃらん

検索画面

jara1.jpg
jara2.jpg

キーボードと被らないようにするためか入力UIをモーダルにして画面トップに出すように工夫されている。これなら大抵の端末やブラウザで対応できそうなので、よくできていると思う。

Amazon.co.jp

レビュー画面

入力状態になると若干のラグの後に画面下に余白ができ、入力状態が外れると同様のラグの後、画面下の余白が消えるという挙動をする。

割と凝ったJSで何かしらの計算を行い、かなり頑張って調整しているようだった。タイムラグがあるのはイベント発火もあるだろうが、キーボードの検出や画面サイズに応じた余白計算に時間がかかっているのもあるのだろう。

ここまで凝った実装をしているのは他のサイトでは見られず、Amazon.co.jp特有に見えた。なお、Amazon.comのほうは見ていない。

あとがき

この調査時点では、じゃらんとAmazon.co.jpを除き入力欄を画面の上部に配置するなどレイアウトで調整しているサイトが比較的多く、どうしてもボタンなどが隠れる傾向があるように思った。

じゃらんは強制的に画面上部に入力欄を出すようにし、Amazon.co.jpは気合でキーボードが隠れないように調整していて、腐心の跡が見られた。

なお、今回動画を作成するにあたり一部をぼかす必要があったため、やり方を調べ実践したのでAviUtlで動画の一部にモザイクをかけ、動かす方法という記事を作成し、その過程を残している。

本動画の作成過程では上手くモザイクをかけられなかったが、上記の記事を作っているときに上手く行くようになったので、本記事の動画はモザイクではなく、ぼかしとなっている。

意外とわかりやすいサイトが少なかったので収穫はあまりないがこんな感じ。消したらログアウトされるCookieとして調べた。

サービス名 保持期間 セッションキー
GitHub 15日 user_session
Cookpad 30日 access_token_global_v2
pixiv 30日 PHPSESSID
fantia 31日 _session_id
ドリパス 2ヶ月 remember_user_token
last.fm 1年 sessionid

Amazon.co.jpやヨドバシもしばらくアクセスしていないとログアウト食らった記憶があるので、何かしらのリミットはついてそうだが、さっと調べる程度の範疇ではわからなかった。

しかしGitHubはやたらログアウト食らうとと思ったらたった15日とは…。

ドリパスはDPSSIDがなければremember_user_tokenからセッションを生成し、DPSSIDremember_user_tokenの両方がなければログアウト状態になるようだった。DPSSIDはExpiresがSessionとなっていたため、remember_user_tokenを削除してもブラウザを落とすまではログインを継続できる。

単体テストは、コードの品質を向上させ、開発効率を高めるために欠かせないものだ。本記事では、単体テストを導入することで得られるメリットとその理由について解説する。

単体テストを書くことについて

単体テストを書くことには賛否両論あると思うが、私は書いたほうが良いと考えている。これは単にテストコードを書くことによってデグレが起きづらくなるからだけではなく、可読性の向上やコンフリクト頻度の減少など、様々な恩恵があるからだ。

この記事では、単体テストを書くことで得られるメリットについて書いていく。特に、関数を活用した開発(クラスを使わないスタイル)、TypeScriptでReactの関数コンポーネントを書くようなケースを前提に話を進める。

関数が小さくなる

単体テストの目的は、関数やクラスといった単位でその振る舞いを検証することだ。しかし、もし関数が1000行に及び、クロージャや分岐、ループが複雑にネストされていると、テストは非常に困難になる。つまり、これを回避しようとすると、自然と関数のサイズは小さくなり、責務が一つにまとまる設計となる。

関数が小さくなることでスコープが短くなり、見通しが良くなるほか、変数名を簡略化できる利点も生まれる。長すぎる変数名は基本的に有害だ。例えば以下のようなコードは誰も見たくないはずだ。(しかもよく見ると罠のようにも見えるコードだ)

const loooooooooooooooooooongVariableNameExtraResult = `${loooooooooooooooooooongVariableNameExtraParameter1}  ${loooooooooooooooooooongVariableNameExtraParameter3} ${shortVar} ${loooooooooooooooooooongVariableNameExtraParameter2}`;

例えば、Go言語では一文字変数が一般的に受け入れられているが、これは関数のスコープが非常に短く、簡潔なコードが成立しやすい文化があるからだ。

疎結合な設計にしやすくなる

密結合なコードはテストがしづらい。そのため、単体テストを書こうとすると、自然と疎結合な設計になりやすくなる。具体的には、依存性逆転の原則に従った設計を採用するようになる。

例えば、次のようなコードはDate ()を直接使用しており、テストが困難だ。このような場合、Date ()をモックするために専用のライブラリやハックを使うことが多くなり、プログラムの保守性が悪化しがちだ。しかし、依存を外部から注入する設計にすれば、そういった心配は不要になる。

const isOneDayLater = (time: number) => {
  return (+new Date() - time) > 86_400_000;
};

しかし、以下のように外部から値を注入すればモックしなくともテストコードを書くのが容易になる。第二引数に値を入れればいいからだ。

const isOneDayLater = (time: number, baseEpochTime: number) => {
  return (baseEpochTime - time) > 86_400_000;
};

コンフリクトが起きづらくなる

Gitを利用した並行開発においてはブランチをマージするときにコンフリクト、つまりコードの競合が起きることがしばしばある。しかしこれまでに書いたように責務別にモジュール化された簡素なコードではコンフリクトの規模が自然に小さくなり、コンフリクト自体が減ったり、万一起きた時でも影響範囲が限定されるようになる。

コンフリクトの解消ミスがあり本番障害が出た、後続のQAフェーズなどで差し戻しが起きたみたいなのはよくある光景だと思うが、単体テストの導入による設計の改善があれば、それが起きた時のコストを減らすのに貢献することが可能だ。

コードの見通しが良くなる

適切に単体テストを意識してコードを書くと、自然と「べた書き」から「モジュール化」されたコードに変わる。

べた書きされたコードの例

例えば次のような関数は冗長だ。この程度の長さならまだしも、これが500行、1000行と増えていくと読むのが大変になる。現実ではもっとネストが酷かったり、前後に長大関数がいて、注意深く読まないと解読不能ということもままある。そしてこの関数をテストするのは書いてある処理すべてを検査する必要があるので、テストコードも長くなり、しんどい。

export const registerUser = async (user: User) => {
  if (!/^\d+$/.test(user.id)) {
    throw new Error('ユーザーIDの書式が不正です。');
  }
  if (user.firstName === '') {
    throw new Error('姓が入力されていません。');
  }
  if (user.lastName === '') {
    throw new Error('名が入力されていません。');
  }
  if (user.gender === '') {
    throw new Error('性別が入力されていません。');
  }
  if (user.address === '') {
    throw new Error('住所が入力されていません。');
  }

  const resp = await fetch('https://example.com/api/v1/user', {
    method: 'POST',
    body: JSON.stringify({
      id: Number(user.id),
      name: `${user.firstName} ${user.lastName}`,
      gender:
        user.gender === '0' ? 'male' : user.gender === '1' ? 'female' : 'other',
      address: user.address,
    }),
  })
    .then(async (resp) => await resp.json())
    .catch((e) => e);

  if (resp.status === 200) {
    return resp;
  } else {
    const payl = resp.json();
    return {
      errorMessage: payl.errorMessage,
    };
  }
};

モジュール化されたコードの例

次のように書けば34行あった関数が9行にまで減り、見通しが良くなる。

export const registerUser = async (user: User) => {
  validateUser(user);

  const payload = createRegisterUserPayload(user);

  const resp = await requestRegisterUser(payload);

  return validateRegisterUserResponse(resp);
};

関数の中で何をしているかわからないという声もあるだろうが、関数名から内容を読み取れるようにしておけば問題にならない。読み取れない処理を入れなければいいのだ。こういう風にべた書きを一定の粒度で関数化することをモジュール化と呼ぶ。

この時、それぞれの関数(validateUser()など)は単一責務を持つため、単体テストのスコープが明確になる。親となる関数(この場合registerUser())のテストでは、子関数をモックすることで簡潔なテストが可能だ。この場合、親となる関数は子関数を呼び出すことが責務になる。

再テストの効率化

コードを直すことはよくあることで、コードを書いているときに一度書いたコードを直したり、レビュー指摘で直したりする。この時に、関係する部分を手動でテストするのは非効率だ。しかし、単体テストがあれば、その工数を大幅に削減できる。

特に分岐の網羅性が重要な場合、単体テストがあるとないとでは開発者の苦痛が大幅に変わるだろう。例えば、4つの分岐があるロジックでさえ、全数網羅では16パターンにも達するため、これを手動で見るのは手間である。しかしテストコードがあれば、自動テストを蹴るだけでいい。心理的負担も時間もこちらの方がローコストで現実的だ。

実装者が設計した時の意図がある程度読み取れる

コードを見ていて「こんなケースはないだろう」と思うことはしばしばあると思うが、そんな時にもしテストコードが存在すればケース名が書いてあるはずだ。それによって当時どのような意図で実装されたのかを知ることができる。これはコードの理解をより深く助ける手助けとなり、「負債に見えたから消したら不具合が出た」といったケースを減らすことに貢献することができる。

手動テストはなくならない

単体テストが充実していても、手動テストが完全になくなるわけではない。特に初回実装時は、プログラムが正しく動作するかどうかを確認する必要があるため、手動テストで動作確認を行い、その上で単体テストを信頼できる状態にするのが基本だ。

単体テストの限界と現実的な運用

さて、ここまで持ち上げてきた単体テストだが、決して銀の弾丸ではない。単体テストを入れてもバグを完全にゼロにすることは出来ないし、正しくないテストコードを書いたり、レビューが不十分であればテストが不正に通過する可能性もある。

正しくないテストコードを書かないための工夫としては、テストコードを書いた後に実装側を修正し、意図的にテストが落ちるようにしたときに、テストが落ちることを確認することが有用だ。これによって、実装が正しくない状態になったときにテストが正しく落ちることが確認できる。逆に落ちなかった場合、そのテストは機能していないと言える。

またテストを全数書くというのも非現実的であるため、TypeScriptの開発であれば型を騙すasのようなものは使わず、極力正しい型を検証できるように静的解析に任せるのが良い。戻り値の型指定も場合によっては型を騙すのに使えるケースがあるため、バグを生み出す元になる場合もあり、気を付けたほうが良い。

何より単体テストで見れるのは単体であり、結合は見れないため、あくまで単体テストはベースのロジックを担保するものとして使い、全体面は結合テストやe2eテスト、手動テストで見ていくのが良いだろう。

理想的には単体テストで網羅し、正常系と異常系のそれぞれ1パターンを結合で、初回実装時のみ手動テストというのがよいと考えている。これは結合テストで網羅ケースを書いてしまうと単体テストを書く意味がないうえに、重複テストが大量に発生し、実行速度が遅くなるし、規模が大きい分メンテナンスが大変になるからだ。

e2eテストは決済などのクリティカルケースにはあったほうが良いと思うが、コストの重さからしてそれ以外にはあまりなくてよいと思う。大抵のケースで手動の方が楽だろう。手動テストでは結果の誤りが出る可能性は残るが、些末な処理で100点を目指しても仕方がないので、多少の不具合は許容で良いのではないかと思う。

単体テストが有効に機能しづらいケース

単体テストは銀の弾丸ではないため、機能しないケースが当然ある。

元から単体テストがないプロジェクトに導入するケース

元々単体テストが導入されていないプロジェクトでは多くの困難を伴う。

例えば、関数やクラスがべた書きされており、責務が曖昧なまま複雑に絡み合っているコードでは、単体テストを書くこと自体が非常に難しい。分解しようにも目に見えている範囲だけを直せないケースも少なくない。これは他の機能がその機能と強く結合しており、根幹となる部分から直していかないと上手くいかないケースがあるからだ。

この状態の関数に対するテストを書いても、リファクタリングで分割された時の動作保証がやりづらい。何故ならこの手のものは大抵きれいに分割できず、再集計になるまでに紆余曲折をたどるからだ。

単体テストに対する理解が異なるケース

「単体テスト」の「単体の単位」が上手く認識されていない場合、本来あるべき単体テストの意義や役割が意識されていないことがある。これは結合テストやe2eテストが単体テストとして扱われているケースだ。

例えば画面の単体テストの定義が、実際に画面を操作し、APIをコールし、その結果に基づいた動作の確認だったり、複数の関数を呼び出す関数を単体として考えているケースでは、単体に対する意識が異なるため、本来あるべき単体テストの導入が難しいことがある。

有効な開発標準やコーディング規約が存在しないケース

有効な開発標準やコーディング規約がなく、コードの裁量がメンバー個々人に委ねられているケースでは、チームメンバーがそれぞれ独自のスタイルでコードを書いていることもあり、より問題を複雑化させることもある。

こういったケースではありとあらゆるコードが想定外の振る舞いをするため、単体テストの導入を阻む障壁となる。

破壊的変更が多いケース

これは要件や仕様などの上流部分が固まらず、日々変動するようなプロジェクトの話だ。

いつ仕様が固まるかわからないのでコードだけは書き続ける必要があり、日々変わるので動作確認もおろそかになる。このようなプロジェクトでは書いたコードが無価値化することが日常であるため、テストコードを書くという行為自体が陳腐化していきがちだ。

単体テストを書く文化がないケース

プロジェクトの方針として単体テストを書くことを推奨していなかったり、認めていない場所がある。こういった場所では、単体テストの重要性が理解されていないことも少なくなく、テストを書くことがむしろ「余計な作業」として扱われることがある。そういったシーンで単体テストを書くと、プロジェクト内で孤立するリスクがあり、難しい。

まとめ

単体テストを書くことで自然とSOLID的な設計に近づき、コードが読みやすくなり、ある程度変更に強く、開発速度も上がり、一定の品質も担保出来るため、単体テストを書くことによる恩恵は少なくない。これは間違いなくメリットだ。

しかし一方で、単体テストは銀の弾丸ではなく、密結合でテストしにくいコードや、頻繁に仕様変更が発生するプロジェクト、文化的に単体テストが認められていない環境では上手く機能しないケースもある。

投稿日:
ライブラリ::Next.js開発::設計

例えば以下のようなコード(例示用のコードなので動作的な意味は特にない)

<>
  {isHoge && obj?.payload?.username !== undefined && (
    <div>新規:{obj.payload.username}</div>
  )}
  {isPiyo && obj?.payload?.username !== undefined && (
    <div>更新:{obj.payload.username}</div>
  )}
  {isFuga && obj?.payload?.acceptTerm !== undefined && (
    <div>退会:{obj.payload.acceptTerm}</div>
  )}
</>

ifやswich文で分岐処理を書く場合はIntelliSenseで漏れが分かるが、JSXに埋め込んで書くと書き漏れがあった場合に気づきづらい。また上記程度ならまだいいと思うが、ネストされていたり、三項演算子を使った複雑な分岐があったりすると読みづらく、何が起きるのかわかりづらいし、テストも書きづらいので避けたほうが良いと感じる。

このような場合はifやswich文で分岐してJSXを返すコンポーネントを別に作り、それを埋め込んだ方が良いと思う。ページ用のtsxファイルの中にこの手のものが大量にいると、大変見通しが悪いと感じる。

そもそも上記の条件はisHoge, isPiyo, isFugaの関連性が分からないため、MECEでなく、条件分岐としてもよくない。

投稿日:
開発::設計ライブラリ::Next.js

Next.jsなどの画面をはじめとしたプログラミング設計において、状態管理は非常に重要な要素だ。特に、複雑なアプリケーションでは、状態がどこに存在するかがコードの可読性や保守性に大きな影響を与える。

基本的に状態は親にすべて望むのが望ましい。これは子が状態を持っている場合に、親がこれを利用することが困難だからだ。例えば以下のようなコンポーネント構造において、子コンポーネントAでAPIをコールし、その結果を持っているとする。仕様変更でこのデータを子コンポーネントBでも利用したいとなった場合、子コンポーネントAから状態を引きはがす必要があるが、実装によっては状態と実装が密結合になっており、特にスパゲッティ化しているコードでは容易に引きはがせないこともあるだろう。

親コンポーネント
 ├子コンポーネントA
 └子コンポーネントB

しかし親であらかじめ状態を持っておけば、このような苦労は不要になる。子AでAPIをコールした結果を親で持っておけば、それをそのままBに引き渡せばいいだけだからだ。

これは信頼できる唯一の情報源にも通じる話で、情報源が分散していると誤って同じ状態を二つ持ってしまったり、実装が複雑化してしまうことがある。酷い場合では子コンポーネントAと親の両方でAPIをコールするような実装もあるだろう。APIコールのタイミングがずれることで期待した通りの結果が得られなかったり、通信が増えることでロードが増えUXを毀損したり、APサーバーの負荷が高まる懸念もある。

基本的に同じ責務を持つ実装はDRYであることが望ましいので、このような悲劇を生まないためにも状態は親で持っておくことが重要だと考える。

逆に責務の範囲に閉じることができるのであれば、子で持ってよいケースもある。例えば親画面から子画面を出すときに、子画面だけに状態をもっていたいケースがある。こういう場合には子に一時的な状態を持つのはありだろう。例えば以下のような保存ボタンを押したときにモーダルの内容を確定するようなものであれば、モーダル内の状態として持ってよい。これは親で使わないからだ。その代わり画面状態の初期化時は親から状態を渡してやり、保存ボタンを押したときはコールバックで通知するという構造だ。

type HogeModalProps = {
  shown: boolean;
  hoge: string;
  onClose: () => void;
  onApplyClose: (state: { hoge: string }) => void;
};

export const HogeModal = (props: HogeModalProps) => {
  const [hoge, setHoge] = useState(props.hoge);

  const shownStyle = props.shown ? { display: 'block' } : { display: 'hidden' };

  const updateHoge = (ev: FormEvent<HTMLInputElement>) => {
    setHoge(ev.currentTarget.value);
  };

  const closeWithApply = () => {
    props.onApplyClose({ hoge });
  };

  return (
    <dialog style={shownStyle}>
      <input type="text" value={hoge} onInput={updateHoge} />
      <button onClick={closeWithApply}>保存</button>
      <button onClick={props.onClose}>キャンセル</button>
    </dialog>
  );
};

このようなケースでは、子の状態を親で管理すると煩雑になるため子に持たせておいた方が良い。