Case Study: Image Manipulation in Automated End-to-End Tests

Automate an iOS Tic Tac Toe app with Appium 2 (XCUITest) Part 2: Player vs Computer.

Test Case:

Test Design

Preparation

Steps

# screenshot then save as PNG in /tmp/base64.png
png_base64 = driver.screenshot_as(:base64)
File.open("/tmp/base64.png", "wb").puts(Base64.decode64(png_base64))
Example base64.png screenshot
width = 1074
height = 1074
# cut images with ImageScience library
ImageScience.with_image("/tmp/base64.png") do |img|
img.with_crop(48, 688, 48 + width, 688 + height) do |crop|
# save the board to /tmp/board.png
crop.save "/tmp/board.png"
end
end
Example board.png
cell_width = width / 3
cell_height = height / 3

ImageScience.with_image("/tmp/board.png") do |img|
cell_list.each do |x|
row = x / 3
col = x % 3
img.with_crop(col * cell_width, row * cell_height, (col + 1) * cell_width, (row + 1) * cell_height) do |crop|
# save each cell and name the file with the row-column coordinates
crop.save "/tmp/cell_#{row}_#{col}.png"
end
end
end
Example cell_0_0.png (upper-left-most cell)

Because of the red cell border and anti-aliasing, there are around 200 colours for a blank cell. I’ve set the limit to 1000 just to be safe.

def is_blank_cell(img)
height = img.dimension.height
width = img.dimension.width
puts "height: #{height}, width: #{width}"
color_codes = []
height.times do |i|
width.times do |j|
arr = [ChunkyPNG::Color.r(img[j, i]), ChunkyPNG::Color.g(img[j, i]), ChunkyPNG::Color.b(img[j, i])]
color_codes << "\##{arr.map { |x| x.to_s(16).rjust(2, "0") }.join.upcase}"
end
end
color_codes.uniq!
return color_codes.size < 1000
end
blank_cells = [0, 1, 2, 3, 4, 5, 6, 7, 8]

# save and crop the board
blank_cells.each do |x|
row = x / 3 # convert index to coordinates
col = x % 3
img = ChunkyPNG::Image.from_file("/tmp/cell_#{row}_#{col}.png")
# remove all cells that are not blank
blank_cells.delete(x) unless is_blank_cell(img)
end

# randomly select one of the blank cells and play it
empty_cell = blank_cells.sample
board[empty_cell].click
Game in progress text (on Computer’s turn)
Game completed text
blank_cells = [0, 1, 2, 3, 4, 5, 6, 7, 8]

while blank_cells.count > 0 && driver.find_element(:class_name, "XCUIElementTypeStaticText")["value"].include?("Game is playing")
sleep 0.5 # wait for computer's turn to finish
if blank_cells.size < 9 # if all blank = 9, the first move, don't need check
save_board_screenshot
smart_crop_board_into_9_images(blank_cells)

blank_cells.each do |x|
row = x / 3
col = x % 3
img = ChunkyPNG::Image.from_file("/tmp/cell_#{row}_#{col}.png")
blank_cells.delete(x) unless is_blank_cell(img)
end
end

puts blank_cells.inspect
empty_cell = blank_cells.sample
board[empty_cell].click
blank_cells.delete(empty_cell)
end

expect(driver.find_element(:class_name, "XCUIElementTypeStaticText")["value"]).to include("Game has ended!")

Video Execution

Complete Test Script

load File.dirname(__FILE__) + "/../test_helper.rb"
require "base64"
require "chunky_png"
# brew install FreeImage, then gem install image_science, https://github.com/seattlerb/image_science
require "image_science"

describe "Player Vs Computer Games" do
include TestHelper

before(:all) do
@driver = Appium::Driver.new(appium_opts).start_driver
end

after(:all) do
driver.quit unless debugging?
end

def smart_crop_board_into_9_images(cell_list)
width = 1074
height = 1074
# cut images with ImageScience library
ImageScience.with_image("/tmp/base64.png") do |img|
img.with_crop(48, 688, 48 + width, 688 + height) do |crop|
crop.save "/tmp/board.png"
end
end

cell_width = width / 3
cell_height = height / 3

ImageScience.with_image("/tmp/board.png") do |img|
cell_list.each do |x|
row = x / 3
col = x % 3
img.with_crop(col * cell_width, row * cell_height, (col + 1) * cell_width, (row + 1) * cell_height) do |crop|
crop.save "/tmp/cell_#{row}_#{col}.png"
end
end
end
end

def save_board_screenshot
png_base64 = driver.screenshot_as(:base64)
File.open("/tmp/base64.png", "wb").puts(Base64.decode64(png_base64))
end

def is_blank_cell(img)
height = img.dimension.height
width = img.dimension.width
puts "height: #{height}, width: #{width}"
color_codes = []
height.times do |i|
width.times do |j|
arr = [ChunkyPNG::Color.r(img[j, i]), ChunkyPNG::Color.g(img[j, i]), ChunkyPNG::Color.b(img[j, i])]
color_codes << "\##{arr.map { |x| x.to_s(16).rjust(2, "0") }.join.upcase}"
end
end
color_codes.uniq!
return color_codes.size < 1000
end

it "Player vs Computer, Taking screenshot" do
driver.find_element(:name, "Player VS Computer").click
sleep 0.5
board = driver.find_elements(:class_name, "XCUIElementTypeImage")
# 0 1 2
# 3 4 5
# 6 7 8

blank_cells = [0, 1, 2, 3, 4, 5, 6, 7, 8]
while blank_cells.count > 0 && driver.find_element(:class_name, "XCUIElementTypeStaticText")["value"].include?("Game is playing")
sleep 0.5 # wait for computer's turn to finish
if blank_cells.size < 9 # if all blank = 9, the first move, don't need check
save_board_screenshot
smart_crop_board_into_9_images(blank_cells)

blank_cells.each do |x|
row = x / 3
col = x % 3
img = ChunkyPNG::Image.from_file("/tmp/cell_#{row}_#{col}.png")
blank_cells.delete(x) unless is_blank_cell(img)
end
end

puts blank_cells.inspect
empty_cell = blank_cells.sample
board[empty_cell].click
blank_cells.delete(empty_cell)
end

expect(driver.find_element(:class_name, "XCUIElementTypeStaticText")["value"]).to include("Game has ended!")
end
end

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store