こんにちは!面白プロデュース事業部のおばらです。
本記事では JavaScript のコードゴルフ大会『JS体操』の全5問を、作問の裏話とあわせて振り返ってみよう思います!
目次
『JS体操』とはカヤックが主催する JavaScript のコードゴルフ大会です。
もともとは社内の勉強会として始めた施策です。その詳細は以下のブログ記事を御覧ください!
【問題】https://hubspot.kayac.com/js-taiso-001
【解説】https://techblog.kayac.com/js-taiso-001-commentary-vol1
記念すべき第1弾!
最初の問題を何にするか、とても迷いました。
運営チームでいくつか問題の候補を出して議論し、問題の意図が見た目にもわかりやすい&過去に似たような事例が少ないということで、グラフを描画する問題にしました。
グラフの形状はいろんなものを試しましたが、グラフの形が面白く、かつコードゴルフのしがいがあるもの、という観点でセレクトしました。
なんと44文字まで縮められます!
exportdefault x=>x-(x%=.2)+.2-(.04-x*x)**.5
以下の5名の方がみごと達成されました!
ここでtkihira さんのとても面白いアプローチの回答をご紹介します。
let r=0;exportdefault x=>r+=x%.2/(1-(5*x%1)**2)**.5/2e5
どういうロジックか、わかりますか?
円の式を微分して、、、微小なΔx分を足していって、、、
こんなアプローチは全く思いつきませんでした。あっぱれです。
赤い点線からちょっとズレているのが御愛嬌ですね!
王道の回答以外にもいろいろ楽しんでいただけるのは出題者としても嬉しいです!
【問題】https://hubspot.kayac.com/js-taiso-002
【解説①】https://techblog.kayac.com/js-taiso-002-commentary-vol1
【解説②】https://techblog.kayac.com/js-taiso-002-commentary-vol2
もともとは「縦横比」のような概念、無次元の概念を導入するとロジックをシンプルにできる!という社内勉強会用のデモでしたが、そこからとんでもないハックが可能な賛否両論の問題になってしまいました。
真面目なアプローチだと66文字!
exportdefault(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b)
以下の2名のみなさん、CONGRATULATIONS♪
デフォルト引数の仕組みを利用するのがポイントですね!
exportdefault()=>9e9<<length+++9
ハック部門では上記コードも想定していましたが、ちょっとやりすぎました。反省。。
【問題】https://hubspot.kayac.com/js-taiso-003
【ヒント①】https://techblog.kayac.com/generating-zalgo-text【ヒント②】https://techblog.kayac.com/unicode-code-point-vs-code-unit【ヒント③】https://techblog.kayac.com/12-ways-to-convert-sparse-array-to-dense-array-on-the-fly【ヒント④】https://techblog.kayac.com/22-ways-to-generate-strings-from-code-points【解説①】https://techblog.kayac.com/js-taiso-003-extension-match-announcement
【解説②】https://techblog.kayac.com/js-taiso-003-commentary-vol1
Unicode について調べているときに思いついた問題です。こういうテキストを Zalgo Text と呼ぶこともこのとき初めて知りました。
3連覇の halwhite さんのおかげで以下まで短くできることが判明しました!
exportdefault(s,r=Math.random)=>s.replace(/./g,c=>(s=n=>~--n?s(n)+String.fromCharCode(768+r()*112):c)(r()*8))
exportdefault s=>s.replace(/./g,c=>c+eval(`''`+`+eval('"\\\\'+'u{'+(768+s()*112|0).toString(16)+'}"')`.repeat(1+(s=Math.random)()*8)))
社内の QA で生まれた、eval()
を2重に使うトンデモ激重コードです。そして文字数はむしろ長くなってますね。テストに本当にものすごく時間がかかるのでお試しいただく際は要注意。
【問題】https://hubspot.kayac.com/js-taiso-004
【解説】https://techblog.kayac.com/js-taiso-004-commentary
社内では、
の2種類のセットで出した問題でした。
それぞれでロジックも変わってくるので面白いです。もし興味のある方はぜひお試しあれ。
1位はksk1015 さんの109文字でした!予想外のロジック、さすが過ぎます。
exportdefault s=>'くしつのへいうこてとひめりるろんあえかけさすせそちにみもやゆよられわおきたぬねはふまむをなほ'.replace(/./g,c=>[...s.matchAll(c)].join``)
【問題】https://hubspot.kayac.com/js-taiso-005
【解説】https://techblog.kayac.com/js-taiso-005-commentary
社内向けのバージョンでは、フリー素材と化している弊社 CBO の写真を AA にする問題でした。
Zalgo Text と AA を組み合わせる案なんてのもありました。薄いピクセルはダイアクリティカルマークを少なく、濃いピクセルはダイアクリティカルマークを多くする、という具合です。
その後、YAPC:Hakodate 2024 開催にあわせた問題にすることが決まり、
などいろいろ考えた結果 YAPC とカヤックのロゴでいくことにしました。
exportdefault(t,W=128,c=newOffscreenCanvas(W,W).getContext`2d`)=>c.getImageData(~~c.drawImage(t,0,0,W,64),0,W,64).data.reduce((a,p,i)=>a+=i%4?~i%512?'':``:'#`'[p>>7],'')
いろいろ遊べるようにあえて非同期の処理も可能にしていたのですが、さすがhalwhite さん、見逃しませんね笑
とてもスマートな回答です!
exportdefault(b,t=document.createElement`canvas`.getContext`2d`)=>fetch(`test-cases/${t.drawImage(b,0,0),t.getImageData(8,32,1,1).data[0]?'kaya':'yap'}c.txt`).then(r=>r.text()
ついでに『JS体操』マスコットキャラクターたちの紹介もしておきます!
お察しの通り、JavaScript で使用する演算子・記号類をキャラクター化したもの。跳び箱などの小物も実は作ってました。笑
3Dモデルは Blender で作成しています!
さて、第1問〜第5問までを振り返ってみましたがいかがだったでしょうか?これで『JS体操』の施策は一旦区切りとなります。問題を作るにあたって JavaScript の言語仕様を学び直したことで私自身とても勉強になりました。オライリーのサイ本第7版を読みながら、あ!これは問題にできそう、みたいなこともありました。業務でハマった経験を問題にすることもありました。そしてなにより挑戦者のみなさんが予想もしていなかったアプローチの回答で想定文字数を超えてくださるのがとても楽しく嬉しかったです。
最近では生の JavaScript を書く機会、細かいロジックを考える機会もなかなか減っているかもしれませんが、こういった形でコードゴルフという形で楽しむのも良いかもしれません。
『JS体操』は一旦終わりですが、社内勉強会用に作った問題は60問以上あるので、いつかまた再開するかもしれません。なお、過去の問題に関してはいつでも挑戦可能ですので、まだやっていなかったという方は以下よりぜひ!
サポートいただいたたくさんの皆様ありがとうございました!
それではまた!!
記事公開時点ではSREの市川です。
というのも2024年の大晦日を以て退職となるのですが、実は【カヤック】面白法人グループ Advent Calendar 2024の7日目の記事をすっぽかしていたので、Go におけるテストの話を書いて置き土産といたします。
以下のようなSUT(テスト対象)があるとします。
package foofunc DoSomething(inputstring)int {// 何かしらの処理}
この限りでは、SUTがエラーを返さないのでエラーチェックの必要はありません。つまり、以下のようなテストコードを書くことができます。
package foo_testimport ("testing""foo"// your SUT package"github.com/stretchr/testify/require")func TestDoSomething(t *testing.T) { tests := []struct { namestring inputstring wantint }{ { name:"when input is foo", input:"foo", want:3, },// 他のテストケースを列挙 }for _, tt :=range tests { t.Run(tt.name,func(t *testing.T) { got := foo.DoSomething(tt.input) require.Equal(t, tt.want, got) }) }}
stretchr/testify
についても、かなり普及しているモジュールだと思うので詳しい解説は割愛しますが、Go のテストにおけるアサーションを行うためのモジュールです。
require
パッケージの諸関数は、要求を満たさなかった場合に当該テストを失敗としてゴルーチンを即時終了させます。t.Run
のコールバックは個別のゴルーチンで実行されるので、上記コードにおいてrequire
が失敗時に中断する検証処理は個々のテストケースに閉じます。
さて、本題に戻って、エラーを返す関数のテストをどう書くかを考えてみましょう。
先ほどの例を以下のように変更した場合を考えます。
package foofunc DoSomething(inputstring) (int,error) {// 何かしらの処理}
この場合、テストコードはどう書けばよいでしょうか?
割とベーシックなのはこのパターンかなと思います。もちろんこれも下の例のようにSUTが返すエラーが変数として定義されていれば過不足なく検証可能です。
func TestDoSomething(t *testing.T) { tests := []struct { name string input string want int+ wantErr error }{ { name: "when input is foo", input: "foo", want: 3, },+ {+ name: "invalid input",+ input: "bar",+ wantErr: foo.ErrInvalidInput,+ }, // 他のテストケースを列挙 } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {- got := foo.DoSomething(tt.input)- require.Equal(t, tt.want, got)+ got, err := foo.DoSomething(tt.input)+ require.ErrorIs(t, err, tt.wantErr)+ if err == nil {+ require.Equal(t, tt.want, got)+ } }) } }
なお、wantErr が暗黙でnil
になっている箇所もありますが、errors.Is(nil, nil)
はtrue
になるので問題ありません。
stretchr/testify
は内部的に Go の標準パッケージのerrors
を使っており、errors.Is()
の挙動はPlaygroundで確かめることが可能です。
これに対して、今回おすすめしたいのは、テストケースにエラーチェック用の関数を追加する方法です。
この方法のメリットは、とにかく柔軟にテストケースを記述できることです。エラーが単純な変数ではなく独自の型として定義されている場合の詳細な比較もできますし、「諸般の事情から文字列チェックをするしかない」みたいなケースにも簡単に対応できます。
func TestDoSomething(t *testing.T) { tests := []struct { name string input string want int+ errorCheck func(*testing.T, error) }{ { name: "when input is foo", input: "foo", want: 3,+ errorCheck: func(t *testing.T, err error) {+ require.NoError(t, err)+ },+ },+ {+ name: "invalid input",+ input: "bar",+ errorCheck: func(t *testing.T, err error) {+ require.ErrorIs(t, err, foo.ErrInvalidInput)+ },+ },+ {+ name: "empty input",+ input: "",+ errorCheck: func(t *testing.T, err error) {+ if !strings.Contains(err.Error(), "empty input") {+ t.Errorf("unexpected error message: %v", err)+ }+ }, }, // 他のテストケースを列挙 } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {- got := foo.DoSomething(tt.input)- require.Equal(t, tt.want, got)+ got, err := foo.DoSomething(tt.input)+ tt.errorCheck(t, err)+ if err == nil {+ require.Equal(t, tt.want, got)+ } }) } }
Go のテーブル駆動テストのコードは、とにかく縦長になる傾向があり、個々のテストケースとt.Run
内の検証処理を行ったり来たりすると疲れが溜まります。
そのため、できれば個々のテストケースだけを見た時に受ける直感を、完全に頼り切れるように設計したいと常々思っています。
今どきLLMに入力補助をしてもらったり様々なソリューションがあるので「タイプ数が多くて面倒臭い」という気持ちとは折り合いをつけやすいですが、それもケースバイケースです。
好みによってはebi-yade/gotest/cases というパッケージが助けになるかもしれません。
以下のように記述することで、エラーチェック関数の記述量を削減することが可能です。
package foo_testimport (// 略"github.com/ebi-yade/gotest/cases")func TestDoSomething(t *testing.T) { tests := []struct { namestring inputstring wantint errorCheckfunc(*testing.T,error) }{ { name:"when input is foo", input:"foo", want:3, errorCheck: cases.NoError, }, { name:"invalid input", input:"bar", errorCheck: cases.ErrorIs(foo.ErrInvalidInput), },//
ソースコードとしてもt.Helper()
を呼んでrequire
をラップしている程度なので、もしインポートすることに抵抗があれば、プロジェクト内のユーティリティパッケージにコピペしていただいても構いません。
テストは書くのも読むのも少なからず負担がかかりますが、テストの品質はソフトウェアの品質に大きな影響を与えます。また、新たにチームに参加したエンジニアの幸福度にも直結する項目でもあると思います。
少しだけ厄介な問題に足を踏み入れることで、自信を持ってチームメンバーに仕事を任せられるようになると良いですね。
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。