2017年5月21日 星期日

Android: FavoriteView - 模擬TwitterLike的效果

前言

最近從Airbnb的Lottie開源程式中,挑了Favorite Star和Twitter Heart的效果來練習,這兩個動畫效果其實相仿,只是使用星星和愛心而已,這篇文章就是來解說如何達到這個效果,效果如下。



以下我歸納為幾個實作步驟:
  1. 製作CircleView類別(中間圓圈的繪製)
  2. 製作SideCircleView類別(外側大小圓圈的繪製)
  3. 製作HeartView類別
  4. 製作FavoriteView類別

製作CircleView類別(中間圓圈的繪製)

中間圓圈分成內圈和外圈兩種,基本上就是繪製兩層圓圈,只是內圈會使用延遲,看起來會有同心圓的效果,以下為重要的程式碼片段。
  • Init(): 這個是初始化方法,分別設置內外圓圈的畫筆。
    private void init() {
        this.outerCirclePaint.setStyle(Paint.Style.FILL);
        this.outerCirclePaint.setColor(COLOR);
        this.innerCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        this.innerCirclePaint.setColor(COLOR);
    }
  • onSizeChanged(): 這個是視圖的生命週期的其中一個方法,當視圖尺寸有變更時所呼叫的方法,我們在這邊設置圓圈最大的尺寸。
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        this.maxCircleSize = w / 2;
        this.tempBitmap = Bitmap.createBitmap(this.getWidth(), this.getWidth(), Bitmap.Config.ARGB_8888);
        this.tempCanvas = new Canvas(this.tempBitmap);
    }
  • onDraw(): 在這邊我們繪製內外圓圈,並且設置兩個變數 - outerCircleRadiusProgress與innerCircleRadiusProgress,提供Animator在數值改變時,同時更新我們的進度。
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        this.tempCanvas.drawColor(0xffffff, PorterDuff.Mode.CLEAR);
        this.tempCanvas.drawCircle(this.getWidth() / 2, this.getHeight() / 2, this.outerCircleRadiusProgress * this.maxCircleSize, this.outerCirclePaint);
        this.tempCanvas.drawCircle(this.getWidth() / 2, this.getHeight() / 2, this.innerCircleRadiusProgress * this.maxCircleSize, this.innerCirclePaint);
        canvas.drawBitmap(this.tempBitmap, 0, 0, null);
    }


製作SideCircleView類別(外側大小圓圈的繪製)

外側圓圈我們共繪製八個,其實你可以使用八種不同的色彩來進行顏色繪製,但這邊我直接使用同一種顏色(0xFF99BBFF),以下為重要的程式碼片段。
  • init(): 這邊初始化八個畫筆,你可以將顏色設定成隨機八種,為求方便,這邊只使用一種。
    private void init() {
        for (int i = 0; i < this.circlePaints.length; i++) {
            this.circlePaints[i] = new Paint();
            this.circlePaints[i].setStyle(Paint.Style.FILL);
            this.circlePaints[i].setColor(COLOR);
        }
    }
  • onSizeChanged(): 同CircleView類別,只是設定中心點的X與Y的位置、尺寸大小、大圓和小圓的半徑。大圓的半徑則為寬度一半小一點,小圓則是一樣,基本上這邊可以自由設置。
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        this.centerX = w / 2;
        this.centerY = h / 2;
        this.maxDotSize = 20;
        this.maxOuterDotsRadius = w / 2 - this.maxDotSize * 2;
        this.maxInnerDotsRadius = this.maxOuterDotsRadius;
    }
  • onDraw(): 這邊需要用到三角函數來計算每個大小圓被分配的弧度,也就是大小圓的實際X和Y,先計算出大圓的位置,小圓則是往左偏移,也就是將大圓的位置扣除欲偏移的距離即可。
    @Override
    protected void onDraw(Canvas canvas) {
        this.drawOuterDotsFrame(canvas);
        this.drawInnerDotsFrame(canvas);
    }

    private void drawOuterDotsFrame(Canvas canvas) {
        for (int i = 0; i < DOTS_COUNT; i++) {
            int cX = (int) (centerX + this.currentRadius1 * Math.cos(i * OUTER_DOTS_POSITION_ANGLE * Math.PI / 180));
            int cY = (int) (centerY + this.currentRadius1 * Math.sin(i * OUTER_DOTS_POSITION_ANGLE * Math.PI / 180));
            canvas.drawCircle(cX, cY, this.currentDotSize1, this.circlePaints[i % this.circlePaints.length]);
        }
    }


    private void drawInnerDotsFrame(Canvas canvas) {
        for (int i = 0; i < DOTS_COUNT; i++) {
            int cX = (int) (centerX + this.currentRadius2 * Math.cos((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180));
            int cY = (int) (centerY + this.currentRadius2 * Math.sin((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180));
            canvas.drawCircle(cX, cY, this.currentDotSize2, this.circlePaints[(i + 1) % this.circlePaints.length]);
        }
    }
  • setCurrentProgress(): 這個方法則是動畫更新時,我們要更新的大小圓繪製進度。大圓則是在進度70%前,維持原本尺寸,在70%後則逐漸消失;小圓分為三種進度,20%前維持原本尺寸,20%至50%則是逐漸出現,之後則是逐漸消失。
    public void setCurrentProgress(float currentProgress) {
        this.currentProgress = currentProgress;
        this.updateOuterDotsPosition();
        this.updateInnerDotsPosition();
        this.postInvalidate();
    }


    private void updateOuterDotsPosition() {
        this.currentRadius1 = (float) this.rangeValue(currentProgress, 0.0f, 1.0f, 0.0f, maxOuterDotsRadius);

        if (currentProgress < 0.7) {
            this.currentDotSize1 = maxDotSize;
        } else {
            this.currentDotSize1 = (float) this.rangeValue(currentProgress, 0.7f, 1.0f, maxDotSize, 0.0f);
        }
    }


    private void updateInnerDotsPosition() {
        this.currentRadius2 = (float) this.rangeValue(currentProgress, 0.0f, 1.0f, 0.0f, maxInnerDotsRadius);

        if (currentProgress < 0.2) {
            this.currentDotSize2 = maxDotSize;
        } else if (currentProgress < 0.5) {
            this.currentDotSize2 = (float) this.rangeValue(currentProgress, 0.2f, 0.5f, maxDotSize, 0.8f * maxDotSize);
        } else {
            this.currentDotSize2 = (float) this.rangeValue(currentProgress, 0.5f, 1.0f, maxDotSize * 0.8f, 0.0f);
        }
    }


    public double rangeValue(double value, double fromLow, double fromHigh, double toLow, double toHigh) {
        return toLow + ((value - fromLow) / (fromHigh - fromLow) * (toHigh - toLow));
    }


製作HeartView類別

先前已介紹過愛心的繪製,這邊就不多做說明,可以參考先前的文章:Android: 製作Heart Beat視圖。

製作FavoriteView類別

這個視圖則是將所有的視圖組合起來,並且加入動畫,以下為重要的程式碼片段。
  • custom_view_favorite.xml
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <com.xy.favoriteview.view.SideCircleView
        android:id="@+id/view_side_circle"
        android:layout_width="120dp"
        android:layout_height="120dp"
        android:layout_gravity="center" />

    <com.xy.favoriteview.view.CircleView
        android:id="@+id/view_circle"
        android:layout_width="58dp"
        android:layout_height="58dp"
        android:layout_gravity="center" />

    <com.xy.favoriteview.view.HeartView
        android:id="@+id/view_heart"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:layout_gravity="center" />
</merge>
  • init()
    private void init() {
        View view = LayoutInflater.from(this.getContext()).inflate(R.layout.custom_view_favorite, this, true);
        this.circleView = (CircleView) view.findViewById(R.id.view_circle);
        this.sideCircleView = (SideCircleView) view.findViewById(R.id.view_side_circle);
        this.heartView = (HeartView) view.findViewById(R.id.view_heart);
    }
  • launchAnim()
    public void launchAnim() {
        this.circleView.setInnerCircleRadiusProgress(0);
        this.circleView.setOuterCircleRadiusProgress(0);
        this.sideCircleView.setCurrentProgress(0);

        final ObjectAnimator outerCircleAnimator = ObjectAnimator.ofFloat(this.circleView, CircleView.OUTER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f);
        outerCircleAnimator.setDuration(300);
        outerCircleAnimator.setInterpolator(this.decelerateInterpolator);

        final ObjectAnimator innerCircleAnimator = ObjectAnimator.ofFloat(this.circleView, CircleView.INNER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f);
        innerCircleAnimator.setDuration(350);
        innerCircleAnimator.setStartDelay(100);
        innerCircleAnimator.setInterpolator(this.decelerateInterpolator);

        final ObjectAnimator sideCircleAnimator = ObjectAnimator.ofFloat(this.sideCircleView, this.sideCircleView.SIDE_CIRCLE_PROGRESS, 0, 1f);
        sideCircleAnimator.setDuration(600);
        sideCircleAnimator.setStartDelay(300);
        sideCircleAnimator.setInterpolator(this.decelerateInterpolator);

        final ValueAnimator heartColorAnimator = ValueAnimator.ofInt(ORIGINAL_COLOR, FINAL_COLOR);
        heartColorAnimator.setEvaluator(this.argbEvaluator);
        heartColorAnimator.setDuration(500);
        heartColorAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                heartView.setHeartColor((int) animation.getAnimatedValue());
            }
        });

        final ObjectAnimator heartScaleYAnimator = ObjectAnimator.ofFloat(this.heartView, ImageView.SCALE_Y, 0.2f, 1f);
        heartScaleYAnimator.setDuration(500);
        heartScaleYAnimator.setInterpolator(this.overshootInterpolator);

        final ObjectAnimator heartScaleXAnimator = ObjectAnimator.ofFloat(this.heartView, ImageView.SCALE_X, 0.2f, 1f);
        heartScaleXAnimator.setDuration(500);
        heartScaleXAnimator.setInterpolator(this.overshootInterpolator);

        this.animatorSet = new AnimatorSet();
        this.animatorSet.playTogether(outerCircleAnimator, innerCircleAnimator, heartColorAnimator, heartScaleYAnimator, heartScaleXAnimator, sideCircleAnimator);
        this.animatorSet.addListener(new AnimatorListenerAdapter() {

            @Override
            public void onAnimationCancel(Animator animation) {
                circleView.setInnerCircleRadiusProgress(0);
                circleView.setOuterCircleRadiusProgress(0);
                sideCircleView.setCurrentProgress(0);
            }
        });
      

Source Code

GitHub: https://github.com/xavier0507/FavoriteView.git

2017年5月2日 星期二

Android: CircleProgress的實作

前言

這次練習做了一個簡單的Circle Progress,產生幾個圓形,再利用Animator物件幫我們達到持續旋轉的效果,效果如下。



以下我歸納為幾個實作步驟:
  1. 初始化
  2. 於量測階段(onMeasure)設定大小
  3. 於大小確定階段設置基本資源和動畫
  4. 於繪製階段繪製所需圓形

初始化

初始化就如同先前的篇章提到的方式,若你希望提供更多的屬性來客製化,就設定自己所需要的屬性資源即可,這邊我直接使用簡單的方式,直接設定單一顏色即可。

private void init() {
        this.viewWidth = 150;
        this.viewHeight = 150;
        this.color = Color.parseColor("#FF4081");
        this.numberOfCircle = 6;
        this.rotates = new float[this.numberOfCircle];
}

CircleProgress的寬高就直接設置150、顏色固定為粉紅色,本身的圓圈和後面跟隨的小圓圈共有六個,當然你可以提供客製化需求,自定義attrs的設定即可。

於量測階段(onMeasure)設定大小

onMeasure這個方法是View的生命週期(LifeCycle)之一,其實相似於Activity或Fragment的生命週期,就是一個View從無到繪製出來的一個過程,然而onMeasure是在View初始化時,需要先對我們的佈局及當中的子視圖進行量測的一個階段,我們在這個階段可以取得量測的大小,或是直接設定我們所要的尺寸,以下我直接將viewWidth=150和viewHeight=150透過setMeasuredDimension方法存取,程式碼如下。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        final int measuredWidth = resolveSize(this.viewWidth, widthMeasureSpec);
        final int measuredHeight = resolveSize(this.viewHeight, heightMeasureSpec);
        this.setMeasuredDimension(measuredWidth, measuredHeight);
}

於大小確定階段設置基本資源和動畫

onLayout階段則是佈局與子視圖都已經確定其大小和位置的階段,這時我們就可以進行我們圓形繪製的前置準備,程式碼如下。

@Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        this.initLayoutSize();
        this.initCircles();
        this.prepareAnim();
}

private void initLayoutSize() {
        this.onLayoutWidth = this.getWidth();
        this.onLayoutHeight = this.getHeight();
        this.center = new PointF(this.onLayoutWidth / 2.0f, this.onLayoutHeight / 2.0f);
}

private void initCircles() {
        final float size = Math.min(this.onLayoutWidth, this.onLayoutHeight);
        final float circleRadius = size / 10.0f;
        this.circles = new Circle[this.numberOfCircle];

        for (int i = 0; i < this.numberOfCircle; i++) {
            this.circles[i] = new Circle();
            this.circles[i].setCenter(this.center.x, circleRadius);
            this.circles[i].setColor(this.color);
            this.circles[i].setRadius(circleRadius - circleRadius * i / 6);
        }
}

private void prepareAnim() {
        for (int i = 0; i < this.numberOfCircle; i++) {
            final int index = i;

            ValueAnimator fadeAnimator = ValueAnimator.ofFloat(360, 0, 0, 360);
            fadeAnimator.setRepeatCount(ValueAnimator.INFINITE);
            fadeAnimator.setDuration(2500);
            fadeAnimator.setStartDelay(index * 100);
            fadeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    rotates[index] = (float) animation.getAnimatedValue();
                    reDraw();
                }
            });

            fadeAnimator.start();
        }
}

private void reDraw() {
        this.invalidate();
}

於繪製階段繪製所需圓形

最後一步則是於onDraw階段繪製我們所有的圓形即可,程式碼如下。

private void drawGraphic(Canvas canvas) {
        for (int i = 0; i < this.numberOfCircle; i++) {
            canvas.save();
            canvas.rotate(this.rotates[i], this.center.x, this.center.y);
            this.circles[i].draw(canvas);
            canvas.restore();
        }
}

Source Code

2017年4月24日 星期一

Android: 會呼吸跳動的聚焦效果

前言

上一篇藉由step的方式,來達到圖形自動增減形成呼吸的跳動效果,這次稍微把程式碼調整加上動畫效果,練習做成一個會呼吸跳動的聚焦效果,效果如下。


以下我歸納為幾個實作步驟:
  1. 初始化
  2. 紀錄HighlightView的相關資料
  3. 動態添加一層可點擊的FrameLayout
  4. 製作HighlightView
  5. 加入進場和退場動畫特效

初始化

初始化分為兩部分,一個是初始欲聚焦視圖的資源,我們可以從Activity傳入所需的配置,像是欲聚焦視圖、聚焦的形狀和疊加上去的背景顏色,程式碼如下。

private void initMemberVariables(Activity activity, View focusedOnView, int highlightShape, int backgroundColor) {
        this.activity = activity;
        this.focusedOnView = focusedOnView;
        this.highlightShape = (highlightShape == 0 ? SHAPE_CIRCLE : SHAPE_RECT);
        this.backgroundColor = backgroundColor != 0 ? this.activity.getResources().getColor(backgroundColor) : this.activity.getResources().getColor(R.color.default_color_CC000000);
}

第二則是儲存手機的寬高,並且取得其中心點的XY值,程式碼如下。

private void initScreenResources() {
        DisplayMetrics displayMetrics = new DisplayMetrics();
        this.activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);

        int deviceWidth = displayMetrics.widthPixels;
        int deviceHeight = displayMetrics.heightPixels;

        this.centerX = deviceWidth / 2;
        this.centerY = deviceHeight / 2;
}

紀錄HighlightView的相關資料

初始化完成後,我們可以記錄一些關於聚焦視圖的資料,像是聚焦視圖的XY值、寬度、高度、繪製圖形的半徑,程式碼如下。
  • 聚焦視圖的XY值可以用getLocationInWindow()方法取得,如此一來,我們就能快速得知聚焦圖形需要繪製在什麼位置,程式碼如下。
int[] viewPoints = new int[2];
focusedOnView.getLocationInWindow(viewPoints);

  • 聚焦視圖的寬高,可以直接利用getWidth()與getHeight()兩個方法取得,程式碼如下。

  • this.focusedOnViewWidth = focusedOnView.getWidth();
    this.focusedOnViewHeight = focusedOnView.getHeight();
    


  • 繪製圖形的半徑要比原本聚焦的視圖來得大一些,所以可以使用聚焦視圖對角線的一半作為半徑,當然你也可以用聚焦視圖的一半,再往外加一點也行。。

  • this.focusedOnViewRadius = (int) (Math.hypot(this.focusedOnViewWidth, this.focusedOnViewHeight) / 2);
    

    動態添加一層可點擊的FrameLayout

    因為我們需要在原本的Acitivity上疊加一層,所以這邊我們動態加入一層FrameLayout,程式碼如下。

    this.highlightContainer = new FrameLayout(this.activity);
    this.highlightContainer.setTag(HIGHLIGHT_CONTAINER_TAG);
    this.highlightContainer.setClickable(true);
    this.highlightContainer.setOnClickListener(this);
    this.highlightContainer.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
    this.mainViewGroup.addView(this.highlightContainer);
    

    製作HighlightView

    我們要客製一個聚焦的視圖,除了基本的繪圓和繪方外,還要用到上篇文章提到的方式,才能讓聚焦視圖產生呼吸跳動的效果,程式碼如下。

    @Override
    protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
    
            if (this.bitmap == null) {
                this.bitmap = Bitmap.createBitmap(this.getWidth(), this.getHeight(), Bitmap.Config.ARGB_8888);
                this.bitmap.eraseColor(this.backgroundColor);
            }
    
            canvas.drawBitmap(this.bitmap, 0, 0, this.backgroundPaint);
    
            if (this.renderHelper.isFocus()) {
                this.animCounter = this.animCounter + this.step;
    
                if (this.renderHelper.getHighlightShape() == ViewManager.SHAPE_CIRCLE) {
                    this.drawCircle(canvas);
                } else {
                    this.drawRoundedRect(canvas);
                }
    
                if (this.animCounter == ANIM_COUNTER_MAX) {
                    this.step = -1;
                } else if (this.animCounter == 0) {
                    this.step = 1;
                }
            }
    
            this.postInvalidate();
    }
    

    加入進場和退場動畫特效

    我們要讓Framlayout產生正確的動畫,所以需要取得正確的寬高,所以須透過getViewTreeObserver()方法,取得正確的數值,才能順利完成動畫效果;而退場效果因為已經繪製完成,所以無需透過該方法取得,可以直接利用getWidth()和getHeight()兩個方法,程式碼如下。
    private void enterAnimation() {
            final int revealRadius = (int) Math.hypot(highlightContainer.getWidth(), highlightContainer.getHeight());
            int startRadius = 0;
    
            if (focusedOnView != null) {
                startRadius = focusedOnView.getWidth() / 2;
            }
    
            Animator enterAnimator = ViewAnimationUtils.createCircularReveal(highlightContainer, centerX, centerY, startRadius, revealRadius);
            enterAnimator.setDuration(1000);
            enterAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
            enterAnimator.start();
    
            this.highlightContainer.getViewTreeObserver().addOnPreDrawListener(
                    new ViewTreeObserver.OnPreDrawListener() {
                        @Override
                        public boolean onPreDraw() {
                            highlightContainer.getViewTreeObserver().removeOnPreDrawListener(this);
    
                            final int revealRadius = (int) Math.hypot(highlightContainer.getWidth(), highlightContainer.getHeight());
                            int startRadius = 0;
    
                            if (focusedOnView != null) {
                                startRadius = focusedOnView.getWidth() / 2;
                            }
    
                            Animator enterAnimator = ViewAnimationUtils.createCircularReveal(highlightContainer, centerX, centerY, startRadius, revealRadius);
                            enterAnimator.setDuration(1000);
                            enterAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
                            enterAnimator.start();
    
                            return false;
                        }
                    });
    }
    

    private void exitAnimation() {
            final int revealRadius = (int) Math.hypot(highlightContainer.getWidth(), highlightContainer.getHeight());
    
            Animator exitAnimator = ViewAnimationUtils.createCircularReveal(highlightContainer, centerX, centerY, revealRadius, 0f);
            exitAnimator.setDuration(300);
            exitAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
            exitAnimator.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    mainViewGroup.removeView(highlightContainer);
                }
            });
            exitAnimator.start();
    }
    

    Source Code

    2017年4月20日 星期四

    Android: 製作Heart Beat視圖

    前言

    做了一個心跳的效果動畫,名為HeartBeatView,不靠Animation或Animator即可達到的客製化視圖,當中還包括可以直接在xml設定其速度和跳動幅度,效果如下:


    這邊我歸納幾個實現的步驟:
    1. 定義HeartBeatView的屬性(declare-styleaqle)
    2. 實作HeartBeatView
    3. 初始化資源檔內容與Paint物件
    4. 繪製愛心
    5. 繪製跳動與幅度

    定義HeartBeatView的屬性(declare-styleaqle) 

    <resources>
        <declare-styleable name="HeartBeatImageView">
            <attr name="animMoveFactor" format="float" />
            <attr name="animCounter" format="integer" />
            <attr name="heartColor" format="color" />
        </declare-styleable>
    </resources>
    

    實作HeartBeatView

    public class HeartBeatImageView extends View {
    }
    

    初始化資源檔內容與Paint物件

    這邊則是在呼叫HeartBeatView的建構子時,順便將我們在xml裡設定的屬性給帶進來(要是你有使用到的話!),可以透過AttributeSet這個參數獲取其在xml設定的內容,程式碼如下。

        public HeartBeatImageView(Context context) {
            this(context, null);
        }
    
        public HeartBeatImageView(Context context, AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public HeartBeatImageView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            this.initParams(context, attrs);
            this.initPaint();
        }
    
        private void initParams(Context context, AttributeSet attrs) {
            TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.HeartBeatImageView);
            this.animMoveFactor = typedArray.getFloat(R.styleable.HeartBeatImageView_animMoveFactor, DEFAUT_ANIM_MOVE_FACTOR);
            this.animCounter = typedArray.getInt(R.styleable.HeartBeatImageView_animCounter, DEFAULT_ANIM_COUNTER);
            this.heartColor = typedArray.getColor(R.styleable.HeartBeatImageView_heartColor, DEFAULT_COLOR);
            typedArray.recycle();
        }
    
        private void initPaint() {
            this.redCirclePaint = new Paint();
            this.redCirclePaint.setAntiAlias(true);
            this.redCirclePaint.setColor(this.heartColor);
        }
    

    繪製愛心

    先繪製一個正方形,再加上兩個圓形,並將之轉成45度即可,程式碼如下。

            int centerX = canvas.getWidth() / 2;
            int centerY = canvas.getHeight() / 2;
            float radius = this.getCircleRadius(canvas.getWidth() / 6, this.animCounter, this.animMoveFactor);
    
            canvas.rotate(45, centerX, centerY);
    
            canvas.drawRect(centerX - radius, centerY - radius, centerX + radius, centerY + radius, this.redCirclePaint);
            canvas.drawCircle(centerX - radius, centerY, radius, this.redCirclePaint);
            canvas.drawCircle(centerX, centerY - radius, radius, this.redCirclePaint);
    

    繪製跳動與幅度

    這邊是透過animCounter與step這兩個參數來控制增減,並期重新更新radius數值,程式碼如下。


        private void drawHeart(Canvas canvas) {
            this.animCounter = this.animCounter + this.step;
    
            int centerX = canvas.getWidth() / 2;
            int centerY = canvas.getHeight() / 2;
            float radius = this.getCircleRadius(canvas.getWidth() / 6, this.animCounter, this.animMoveFactor);
    
            canvas.rotate(45, centerX, centerY);
    
            canvas.drawRect(centerX - radius, centerY - radius, centerX + radius, centerY + radius, this.redCirclePaint);
            canvas.drawCircle(centerX - radius, centerY, radius, this.redCirclePaint);
            canvas.drawCircle(centerX, centerY - radius, radius, this.redCirclePaint);
    
            if (this.animCounter >= ANIM_COUNTER_MAX) {
                this.step = -1;
            } else if (this.animCounter <= 0) {
                this.step = 1;
            }
        }
    
        private float getCircleRadius(int radius, int animCounter, double animFactor) {
            return (float) (radius + animCounter * animFactor);
        }
    

    2017年4月17日 星期一

    Android: 下雪撒圖的效果製作

    前言

    由於想研究關於UI方面的議題,所以最近試著寫一個客製化的FrameLayout,可以顯示撒圖的效果,這篇文章主要就是介紹概念與一些作法。這裡我製作了一個名為SnowEffectFrameLayout的客製化PercentFrameLayout,這個Layout主要是用來控制圖片的動畫與生成,當中還包括一些參數的設定。我們先來看一下實際效果,效果如下:


    這邊我歸納幾個實現的步驟:
    1. 定義SnowEffectFrameLayout的屬性(declare-styleaqle)
    2. 實作SnowEffectFrameLayout
    3. 初始化資源檔內容
    4. 初始化圖片物件池
    5. 設定每張圖片的TranslateAnimation

    定義SnowEffectFrameLayout的屬性(declare-styleaqle)

    <resources>
        <declare-styleable name="SnowEffectFrameLayout">
            <attr name="snowBasicCount" format="integer" />
            <attr name="dropAverageDuration" format="integer" />
            <attr name="isRotation" format="boolean" />
        </declare-styleable>
    </resources>
    

    實作SnowEffectFrameLayout

    首先讓SnowEffectFrameLayout繼承PercentFrameLayout,主要是讓每個圖片能夠利用PercentFrameLayout的百分比特性,均勻地分布在螢幕的X軸上,至於什麼是PercentFrameLayout,其實就是透過百分比來控制子視圖的大小,程式碼如下。

    # Gradle: dependencies

    compile 'com.android.support:percent:25.3.1'

    # Java

    public class SnowEffectFrameLayout extends PercentFrameLayout {
    }
    

    初始化資源檔內容

    這邊則是在呼叫SnowEffectFrameLayout的建構子時,順便將我們在xml裡設定的屬性給帶進來(要是你有使用到的話!),可以透過AttributeSet這個參數獲取其在xml設定的內容,程式碼如下。

    private void init(Context context, AttributeSet attrs) {
            TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SnowEffectFrameLayout);
            this.snowBasicCount = typedArray.getInteger(R.styleable.SnowEffectFrameLayout_snowBasicCount, DEFAULT_SNOW_BASIC_COUNT);
            this.dropAverageDuration = typedArray.getInteger(R.styleable.SnowEffectFrameLayout_dropAverageDuration, DEFAULT_DROP_AVERAGE_DURATION);
            this.isRotation = typedArray.getBoolean(R.styleable.SnowEffectFrameLayout_isRotation, DEFAULT_IS_ROTATION);
            this.dropFrequency = DEFAULT_DROP_FREQUENCY;
    
            this.snowList = new ArrayList<>();
            if ((this.snowList == null || this.snowList.size() == 0)) {
                this.snowList.add(ContextCompat.getDrawable(this.getContext(), R.drawable.snow));
            }
    }
    

    初始化圖片物件池

    為了要能重複利用圖片,這邊初始了一個物件池,用來存放這些大量的視圖,並且不用每次重新產生,造成資源的浪費,程式碼如下。

    private void initSnowPool() {
            final int snowCount = this.snowList.size();
            if (snowCount == 0) {
                throw new IllegalStateException("There are no drawables.");
            }
    
            this.cleanSnowPool();
    
            final int expectedMaxSnowCountOnScreen = (int) ((1 + RELATIVE_DROP_DURATION_OFFSET) * snowBasicCount * dropAverageDuration / ((float) dropFrequency));
            this.snowPool = new Pools.SynchronizedPool<>(expectedMaxSnowCountOnScreen);
            for (int i = 0; i < expectedMaxSnowCountOnScreen; i++) {
                final ImageView snow = this.generateSnowImage(this.snowList.get(i % snowCount));
                this.addView(snow, 0);
                this.snowPool.release(snow);
            }
    
            RandomTool.setSeed(10);
    }
    

    設定每張圖片的TranslateAnimation

    最後就是透過TranslateAnimation讓每張圖片呈現撒落的感覺,再結合RotateAnimation在撒落同時能有旋轉的效果,程式碼如下。

    private void startDropAnimationForSingleSnow(final ImageView snow) {
            final int currentDuration = (int) (this.dropAverageDuration * RandomTool.floatInRange(1, RELATIVE_DROP_DURATION_OFFSET));
    
            final AnimationSet animationSet = new AnimationSet(false);
            final TranslateAnimation translateAnimation = new TranslateAnimation(
                    Animation.RELATIVE_TO_SELF, 0,
                    Animation.RELATIVE_TO_SELF, RandomTool.floatInRange(0, 5),
                    Animation.RELATIVE_TO_PARENT, 0,
                    Animation.ABSOLUTE, this.windowHeight);
    
            if (this.isRotation) {
                final RotateAnimation rotateAnimation = new RotateAnimation(
                        0,
                        RandomTool.floatInRange(0, 360),
                        Animation.RELATIVE_TO_SELF,
                        0.5f,
                        Animation.RELATIVE_TO_SELF,
                        0.5f);
                animationSet.addAnimation(rotateAnimation);
            }
    
            animationSet.addAnimation(translateAnimation);
            animationSet.setDuration(currentDuration);
            animationSet.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
                }
    
                @Override
                public void onAnimationEnd(Animation animation) {
                    snowPool.release(snow);
                }
    
                @Override
                public void onAnimationRepeat(Animation animation) {
                }
            });
    
            snow.startAnimation(animationSet);
        }
    

    以上還有些小細節沒有交代,就是透過亂數的方式製造位置和動畫速度不一的落差,但這邊不多做贅言,實際的程式碼則在GitHub上,有興趣的人可以參考看看。

    Source Code

    GitHub: https://github.com/xavier0507/SnowEffect.git


    2016年12月8日 星期四

    Testing: Android的單元測試相關內容

    前言

    因為自己在學習單元測試及閱讀相關書籍的過程中,總覺得資料很多,但觀念都很分散,無法很有系統地瞭解單元測試議題,所以在自我學習後,我練習一些例子幫助我瞭解單元測試,所以將這些內容有系統並循序漸進地整理起來。

    相關觀念


    框架使用


    Testing: 隔離框架(Isolation framework) - 使用PowerMock驗證靜態方法

    前言

    在Testing: 隔離框架(Isolation framework) - 使用Mockito建立測試替身一文中,我們練習如何使用Mockito框架模擬測試替身後,接著要來學習PowerMock的框架,輔助我們測試與驗證靜態方法。

    PowerMock的使用

    PowerMock使用起來相當簡單,首先我們在gradle中引用PowerMock,程式碼如下。

    dependencies {
        testCompile 'org.powermock:powermock-module-junit4:1.6.2'
        testCompile 'org.powermock:powermock-api-mockito:1.6.2'
    }
    

    以RegisterManager為例

    • RegisterManager物件
    public class RegisterManager {
        private IEmailRegister emailRegister;
        private IMobileRegister mobileRegister;
    
        public RegisterManager(IEmailRegister emailRegister, IMobileRegister mobileRegister) {
            this.emailRegister = emailRegister;
            this.mobileRegister = mobileRegister;
        }
    
        public void register(String content) {
            if (RegistrationFormat.checkFormat(content)) {
                this.emailRegister.emailRegister();
            } else {
                this.mobileRegister.mobileRegister();
            }
        }
    }
    • RegistrationFormat類別
    public class RegistrationFormat {
    
        public static boolean checkFormat(String content) {
            boolean isEmail = false;
    
            if (content.contains("@")) {
                return isEmail;
            }
    
            return !isEmail;
        }
    }
    • IEmailRegister介面
    public interface IEmailRegister {
        void emailRegister();
    }
    • IMobileRegister介面
    public interface IMobileRegister {
        void mobileRegister();
    }
    

    RegisterManager的功能相當簡單,它提供了Email與Mobile這兩種註冊方法,透過RegistrationFormat類別的靜態方法檢查,若是屬於Email格式,就使用Email註冊,Mobile註冊則反之,接著我們來看測試程式。

    @RunWith(PowerMockRunner.class)
    @PrepareForTest(RegistrationFormat.class)
    public class RegisterManagerWithPowerMockitoTest {
    
        @Test
        public void testRegisterIsEmail() throws Exception {
            // Arrange
            IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
            IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
            PowerMockito.mockStatic(RegistrationFormat.class);
            Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(true);
            RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);
    
            // Act
            registerManager.register(Mockito.anyString());
    
            // Assert
            Mockito.verify(mockEmailRegister).emailRegister();
        }
    
        @Test
        public void testRegisterIsMobile() throws Exception {
            // Arrange
            IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
            IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
            PowerMockito.mockStatic(RegistrationFormat.class);
            Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false);
            RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);
    
            // Act
            registerManager.register(Mockito.anyString());
    
            // Assert
            Mockito.verify(mockMobileRegister).mobileRegister();
        }
    
        @Test
        public void testRegistrationFormatCheckFormatIsCalled() throws Exception {
            // Arrange
            IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
            IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
            PowerMockito.mockStatic(RegistrationFormat.class);
            Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false);
            RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);
    
            // Act
            registerManager.register(Mockito.anyString());
    
            // Assert
            PowerMockito.verifyStatic(Mockito.times(1));
            RegistrationFormat.checkFormat(Mockito.anyString());
        }
    }
    

    要使用PowerMock首先要依循以下方式:
    • @RunWith(PowerMockRunner.class): 使用@RunWith的Annotation,並且類別指定PowerMockRunner.class。
    • @PrepareForTest(RegistrationFormat.class): 使用@PrepareForTest的Annotation,聲明要Mock的靜態方法類別,這邊指定RegistrationFormat.class。
    • PowerMockito.mockStatic(RegistrationFormat.class): 使用mockStatic方法Mock靜態類別

    接著預期我們的測試程式,可以使用下列方法。
    • Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false): 如同Mockito的方法,我們指定RegistrationFormat使用checkFormat的靜態方法時,所欲回傳的值。
    • PowerMockito.verifyStatic(Mockito.times(1)): 先斷言該靜態方法執行的次數。
    • RegistrationFormat.checkFormat(Mockito.anyString()): 實際執行該方法。

    這邊需要注意的是,PowerMock斷言的方式有些奇怪,要先斷言再執行呼叫,值得注意喔!這邊也能順利通過測試,如下圖。


    小結

    我們都順利討論完隔離框架的使用啦!但目前為止,這些議題是個人學習單元測試上,覺得非常重要的觀念,希望對大家都有幫助,之後也會不定期更新相關議題,若有任何意見,也歡迎提供給我,謝謝大家!

    Source Code

    Github: https://github.com/xavier0507/UnitTestSample.git

    Testing: 隔離框架(Isolation framework) - 使用Mockito建立測試替身

    前言

    之前花了很多篇章在提手動建置測試替身,也從練習中了解為何要有測試替身和依賴注入等觀念,但手動建置有些問題存在,也顯得非常不方便,這些測試替身遲早會越來越多,相對地,我們光是維護就很耗時間,所以我們需要隔離框架(Isolation framework)來輔助我們,這也是接下來要提到的內容。

    手動建置測試替身的問題

    • 使用手動建置測試替身會有下列問題:
    • 建置測試替身很耗時間。
    • 測試替身多了,也是要維護的。
    • 若測試替身要保存多次呼叫的狀態時,你需要在測試替身實現相關程式碼。
    • 很難在其他測試重複使用測試替身,可能基本程式碼可以使用,但是介面有超過三個以上方法需要實現時,維護就成了問題。
    所以為了讓我們更方便使用測試替身,我們需要隔離框架的協助,也就是一套提供公開使用的API,藉由這些API自動建置測試替身,可以比手動建置來得容易、有效率,而且更為簡潔。每個語言的單元測試框架大部分都有隔離框架,C++有mockpp和其他框架,Java有jMock、PowerMock...等,而在Android上則廣泛使用Mockito和PowerMock這兩套框架。

    Mockito的使用

    手動建置的測試替身就不再一一說明,先前都有足夠的例子可以練習,直接進入該如何使用Mockito來建置測試替身。首先,若要在Android Studio中使用Mockito框架,要先在gradle中加入Mockito,設定如下。

    dependencies {
        testCompile 'org.mockito:mockito-core:2.2.28'
    }
    

    設定之後,我們都知道測試替身的用意,而使用Mockito來建置Mock物件有兩種做法,一個是利用mock這個方法(即使是Stub物件也是利用mock方法),另一種是@Mock這個Annotation,先介紹前者,這邊會以先前的FileParser和Bookstore來做練習。

    以FileParser為例:mock方法

    • FileParserWithMockitoTest測試程式
    public class FileParserWithMockitoTest {
    
        @Test
        public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
            Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
            FileParser fileParser = new FileParser(stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        @Test
        public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
            Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
            FileParser fileParser = new FileParser(stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
    
            // Assert
            assertThat(actualResult, is(true));
        }
    
        @Test
        public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
            Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
            FileParser fileParser = new FileParser(stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        @Test
        public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
            Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
            FileParser fileParser = new FileParser(stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        @Test
        public void testVerifyIExtensionManagerCalledOneTimes() throws Exception {
            // Arrange
            IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
            Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
            FileParser fileParser = new FileParser(stubExtensionManager);
    
            // Act
            fileParser.isValidLogFileName("short_file_name.txt");
    
            // Assert
            // 當沒有指定times時,預設就是1次
            Mockito.verify(stubExtensionManager).isValid(Mockito.anyString());
            Mockito.verify(stubExtensionManager, Mockito.times(1)).isValid(Mockito.anyString());
        }
    }
    

    可以對照上述程式,用到Mockito框架的以下方法:
    • mock(Class<T> classToMock): 這邊是指定要模擬的類別或是介面。以上述程式為例,需要Mock的則是IExtensionManager。
    • when(T methodCall): 這裡表示要呼叫Mock的某個方法。就如同先前手動建立Stub時,我們希望呼叫IExtensionManager的isValid方法。
    • thenReturn(T value): 預期Mock呼叫某個方法後,所要回傳的值。我們希望當呼叫isValid方法之後,回傳true。
    • verify(T mock) / verify(T mock, VerificationMode mode): 檢驗Mock呼叫了某個方法幾次。以上述程式碼為例,Mockito.verify(stubExtensionManager, Mockito.times(1)).isValid(Mockito.anyString()),這段程式碼表示驗證isValid方法被呼叫了1次。
    藉由Mockito提供的方法,我們就完成原本手動建置Stub的功能了,我們也能順利通過測試,如下圖。


    以FileParser為例:@Mock的Annotation使用

    • FileParserWithMockitoAnnotationTest測試程式
    public class FileParserWithMockitoAnnotationTest {
        @Mock
        private IExtensionManager stubExtensionManager;
    
        @Before
        public void setUp() throws Exception {
            MockitoAnnotations.initMocks(this);
        }
    
        @Test
        public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
            Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
            FileParser fileParser = new FileParser(this.stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        @Test
        public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
            FileParser fileParser = new FileParser(this.stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
    
            // Assert
            assertThat(actualResult, is(true));
        }
    
        @Test
        public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
            FileParser fileParser = new FileParser(this.stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        @Test
        public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
            FileParser fileParser = new FileParser(this.stubExtensionManager);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    }
    

    Annotation的使用很簡單,只要在Mock的類別或介面加上@Mock,接著使用MockitoAnnotations.initMocks(Object testClass)方法初始化即可,同樣方式也能順利通過測試喔!如下圖。


    以Bookstore為例:mock方法

    • BookStoreWithMockitoTest測試程式
    public class BookStoreWithMockitoTest {
    
        @Test
        public void testCheckInFeeNotVIP() throws Exception {
            // Arrange
            ICheckInFee mockCheckInFee = Mockito.mock(ICheckInFee.class);
            BookStore bookStore = new BookStore(mockCheckInFee);
    
            // Act
            bookStore.checkInFee(this.getCustomers());
    
            // Assert
            Mockito.verify(mockCheckInFee, Mockito.times(2)).getFee(Mockito.any(Customer.class));
        }
    
        @Test
        public void testCheckInFeeIsVIP() throws Exception {
            // Arrange
            ICheckInFee mockCheckInFee = Mockito.mock(ICheckInFee.class);
            BookStore bookStore = new BookStore(mockCheckInFee);
    
            // Act
            bookStore.checkInFee(this.getCustomers());
    
            // Assert
            Mockito.verify(mockCheckInFee, Mockito.times(4)).getDiscountedFee(Mockito.any(Customer.class));
        }
    
        @Test
        public void testGetIncome() throws Exception {
            // Arrange
            ICheckInFee mockCheckInFee = Mockito.mock(ICheckInFee.class);
            Mockito.when(mockCheckInFee.getFee(Mockito.any(Customer.class))).thenReturn(1000);
            Mockito.when(mockCheckInFee.getDiscountedFee(Mockito.any(Customer.class))).thenReturn(1000 * 0.8);
            BookStore bookStore = new BookStore(mockCheckInFee);
    
            // Act
            bookStore.checkInFee(this.getCustomers());
    
            // Assert
            assertThat(bookStore.getIncome(), equalTo(5200.0));
        }
    
        private List<Customer> getCustomers() {
            List<Customer> customers = new ArrayList<>();
            Customer customer1 = new Customer();
            Customer customer2 = new Customer();
            Customer customer3 = new Customer();
            Customer customer4 = new Customer();
            Customer customer5 = new Customer();
            Customer customer6 = new Customer();
            customer1.setVIP(true);
            customer2.setVIP(false);
            customer3.setVIP(false);
            customer4.setVIP(true);
            customer5.setVIP(true);
            customer6.setVIP(true);
            customers.add(customer1);
            customers.add(customer2);
            customers.add(customer3);
            customers.add(customer4);
            customers.add(customer5);
            customers.add(customer6);
    
            return customers;
        }
    }
    

    這邊的做法和FileParser的方式差不多,只是驗證結果有些差異,在Bookstore的checkInFee是void方法,我們測試則著重在ICheckInFee的getFee和getDiscountedFee方法是否被正確呼叫,而且呼叫次數是否如我們預期。以下是使用到Mockito的其他方法。
    • any(Class<T> class): 這個方法表示不限定傳入特定資料,只要是同型態即可,類似的還有anyString()。
    最後也是順利通過測試囉,如下圖。


    以Bookstore為例:@Mock的Annotation使用

    • BookStoreWithMockitoAnnotationTest測試程式
    public class BookStoreWithMockitoAnnotationTest {
        @Mock
        private ICheckInFee mockCheckInFee;
    
        private BookStore bookStore;
    
        @Before
        public void setUp() throws Exception {
            MockitoAnnotations.initMocks(this);
        }
    
        @Test
        public void testCheckInFeeNotVIP() throws Exception {
            // Arrange
            this.bookStore = new BookStore(this.mockCheckInFee);
    
            // Act
            this.bookStore.checkInFee(this.getCustomers());
    
            // Assert
            Mockito.verify(mockCheckInFee, Mockito.times(2)).getFee(Mockito.any(Customer.class));
        }
    
        @Test
        public void testCheckInFeeIsVIP() throws Exception {
            // Arrange
            this.bookStore = new BookStore(this.mockCheckInFee);
    
            // Act
            this.bookStore.checkInFee(this.getCustomers());
    
            // Assert
            Mockito.verify(mockCheckInFee, Mockito.times(4)).getDiscountedFee(Mockito.any(Customer.class));
        }
    
        @Test
        public void testGetIncome() throws Exception {
            // Arrange
            Mockito.when(this.mockCheckInFee.getFee(Mockito.any(Customer.class))).thenReturn(1000);
            Mockito.when(this.mockCheckInFee.getDiscountedFee(Mockito.any(Customer.class))).thenReturn(1000 * 0.8);
            BookStore bookStore = new BookStore(this.mockCheckInFee);
    
            // Act
            bookStore.checkInFee(this.getCustomers());
    
            // Assert
            assertThat(bookStore.getIncome(), equalTo(5200.0));
        }
    
        private List<Customer> getCustomers() {
            List<Customer> customers = new ArrayList<>();
            Customer customer1 = new Customer();
            Customer customer2 = new Customer();
            Customer customer3 = new Customer();
            Customer customer4 = new Customer();
            Customer customer5 = new Customer();
            Customer customer6 = new Customer();
            customer1.setVIP(true);
            customer2.setVIP(false);
            customer3.setVIP(false);
            customer4.setVIP(true);
            customer5.setVIP(true);
            customer6.setVIP(true);
            customers.add(customer1);
            customers.add(customer2);
            customers.add(customer3);
            customers.add(customer4);
            customers.add(customer5);
            customers.add(customer6);
    
            return customers;
        }
    }
    

    使用上是一樣的,所以不多做說明,大家自行練習。測試結果同樣順利,如下圖。


    小結

    目前為止跟大家分享了Mockito基本的使用,若想要知道更多用法,可以至其它網站資訊查詢。然而目前只是針對一般介面或類別驗證,有時會需要對靜態類別進行檢查,這時可以使用PowerMock,也是接下來要介紹的,持續練習單元測試吧!

    Source Code

    Github: https://github.com/xavier0507/UnitTestSample.git

    2016年12月7日 星期三

    Testing: 再論依賴注入

    前言

    前一篇文章Testing: 再論測試替身中,已經討論全部的測試替身,也了解測試替身的目的,接著本文再討論其它的依賴注入方式。

    再論依賴注入

    先前介紹建構子依賴注入時,有提到所謂的依賴注入就是透過一個基於介面的入口,我們可以在一個類別中注入一個介面的實作,讓它的方法可以利用這種介面來實現,而依賴注入分為下列幾種方式。我們已經介紹過建構子這個依賴注入方式,接著繼續介紹其它建構子的注入方法。
    • 建構子(Constructor)
    • Setter方法
    • 工廠類別(Factory Class)
    • 工廠方法(Factory Method)
    • 映射(Reflection)

    建構子依賴注入

    先前介紹的是利用建構子聲明一個參數,將所需的依賴型態傳入,但有時我們不見得會有參數的建構子,若在無參數的建構子時,我們又該如何設計注入?同樣以先前的FileParser物件來說明,先建立一個公開的無參數建構子,程式碼如下。
    • FileParserWithoutParameterConstructor物件
    public class FileParser {
        private IExtensionManager fileExtensionManager;
    
        public FileParser() {
            this(new FileExtensionManagerImp());
        }
    
        protected FileParser(IExtensionManager fileExtensionManager) {
            this.fileExtensionManager = fileExtensionManager;
        }
    
        public boolean isValidLogFileName(String fileName) {
            return this.fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
        }
    }
    • StubExtensionManager物件
    public class StubExtensionManager implements IExtensionManager {
        public boolean shouldExtensionsBeValid;
    
        @Override
        public boolean isValid(String fileName) {
            return this.shouldExtensionsBeValid;
        }
    }
    • FileParserWithoutParameterConstructorTest測試程式
    public class FileParserWithoutParameterConstructorTest {
    
        @Test
        public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = true;
            FileParserProxy fileParser = new FileParserProxy(fake);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        @Test
        public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = true;
            FileParserProxy fileParser = new FileParserProxy(fake);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
    
            // Assert
            assertThat(actualResult, is(true));
        }
    
        @Test
        public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = false;
            FileParserProxy fileParser = new FileParserProxy(fake);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        @Test
        public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = false;
            FileParserProxy fileParser = new FileParserProxy(fake);
    
            // Act
            boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
    
            // Assert
            assertThat(actualResult, is(false));
        }
    
        private class FileParserProxy extends FileParserWithoutParameterConstructor {
    
            public FileParserProxy(IExtensionManager fileExtensionManager) {
                super(fileExtensionManager);
            }
        }
    }
    

    從上面可以看出,只是藉由FileParserProxy來繼承一個FileParserWithoutParameterConstructor,並為FileParserProxy設計一個公開且帶有參數的建構子,這樣測試程式則可以使用它並傳入想使用的Stub物件,替換掉我們的依賴物件了,測試也同樣順利完成,如下所示。


    Setter依賴注入

    Setter依賴注入也很簡單,只要使用一個Setter方法,並且將依賴傳入即可,程式碼如下。
    • FileParserWithSetterInjection物件
    public class FileParserWithSetterInjection {
        private IExtensionManager extensions;
    
        public FileParserWithSetterInjection() {
            this.extensions = new FileExtensionManagerImp();
        }
    
        public void setExtensionManager(IExtensionManager extensions) {
            this.extensions = extensions;
        }
    
        public boolean isValidLogFileName(String fileName) {
            return extensions.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
        }
    }
    
    • FileParserWithSetterInjectionTest測試程式
    public class FileParserWithSetterInjectionTest {
    
        @Test
        public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = true;
    
            // Act
            FileParserWithSetterInjection log = new FileParserWithSetterInjection();
            log.setExtensionManager(fake);
    
            // Assert
            Assert.assertFalse(log.isValidLogFileName("short.ext"));
        }
    }
    

    從以上程式碼可以看出,我們多加入一個setExtensionManager方法,與建構子依賴注入的方式差不多,測試時就直接將Stub傳入即可,我們同樣順利通過測試囉!


    工廠類別依賴注入

    工廠類別依賴注入也很簡單,只是將注入的方法移到一個單例(Singleton)的工廠類別物件,再提供一個setInstance的方法就完成,程式碼如下。
    • FileParserWithFactoryClassInjection物件
    public class FileParserWithFactoryClassInjection {
        private IExtensionManager fileExtensionManager;
    
        public FileParserWithFactoryClassInjection() {
            this.fileExtensionManager = ExtensionManagerFactory.create();
        }
    
        public boolean isValidLogFileName(String fileName) {
            return this.fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
        }
    }
    • ExtensionManagerFactory工廠物件
    public class ExtensionManagerFactory {
        private static IExtensionManager customImplementation = null;
    
        public static IExtensionManager create() {
            if (customImplementation != null) {
                return customImplementation;
            }
    
            return new FileExtensionManagerImp();
        }
    
        public static void setInstance(IExtensionManager implementation) {
            customImplementation = implementation;
        }
    }
    • FileParserWithFactoryClassInjectionTest測試程式
    public class FileParserWithFactoryClassInjectionTest {
    
        @Test
        public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = true;
            ExtensionManagerFactory.setInstance(fake);
    
            // Act
            FileParserWithFactoryClassInjection log = new FileParserWithFactoryClassInjection();
    
            // Assert
            assertFalse(log.isValidLogFileName("short.exe"));
        }
    }
    

    看到了?我們透過ExtensionManagerFactory.setInstance(fake)這樣的方式,把Stub給注入進去,接著就能順利通過測試啦!如下圖。


    工廠方法依賴注入

    工廠方法依賴注入則是在待測物件內建立一個工廠方法,產生相對應的依賴物件,當測試時覆寫該方法替換想要的依賴物件即可,程式如下。
    • FileParserWithFactoryMethodInjection物件
    public class FileParserWithFactoryMethodInjection {
    
        protected IExtensionManager getExtensionManager() {
            return new FileExtensionManagerImp();
        }
    
        public boolean isValidLogFileName(String fileName) {
            return getExtensionManager().isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
        }
    }
    • FileParserWithFactoryMethodInjectionTest測試程式
    public class FileParserWithFactoryMethodInjectionTest {
    
        @Test
        public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
            // Arrange
            final StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = true;
            FileParserWithFactoryMethodInjection log = new FileParserWithFactoryMethodInjection() {
    
                @Override
                protected IExtensionManager getExtensionManager() {
                    return fake;
                }
            };
    
            // Act
            boolean actualResult = log.isValidLogFileName("shortName.ext");
    
            // Assert
            Assert.assertTrue(actualResult);
        }
    
        @Test
        public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtensions() throws Exception {
            // Arrange
            final StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = true;
            FileParserWithFactoryMethodInjectionProxy log = new FileParserWithFactoryMethodInjectionProxy();
            log.extensionManager = fake;
    
            // Act
            boolean actualResult = log.isValidLogFileName("shortName.ext");
    
            // Assert
            Assert.assertTrue(actualResult);
        }
    
        class FileParserWithFactoryMethodInjectionProxy extends FileParserWithFactoryMethodInjection {
            public IExtensionManager extensionManager;
    
            @Override
            protected IExtensionManager getExtensionManager() {
                return this.extensionManager;
            }
        }
    }
    

    在測試時有兩種方式,一種你可以另外產生Stub物件,像是上述程式內的FileParserWithFactoryMethodInjectionProxy類別,另一種則是在測試案例中用匿名類別的方式也是能達到同樣目的,如同testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension測試案例一樣,透過這種方式,我們也能順利通過測試,如下圖。


    映射依賴注入

    映射依賴注入比較麻煩,就是要利用映射機制來修改原本屬性的存取權限,然後把依賴物件給注入,程式碼如下。
    • FileParserWithReflectionInjection物件
    public class FileParserWithReflectionInjection {
        private IExtensionManager extensionManager;
    
        public FileParserWithReflectionInjection() {
            this(new FileExtensionManagerImp());
        }
    
        protected FileParserWithReflectionInjection(IExtensionManager extensionManager) {
            this.extensionManager = extensionManager;
        }
    
        public boolean isValidLogFileName(String fileName) {
            return this.extensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
        }
    }
    • FileParserWithReflectionInjectionTest測試程式
    public class FileParserWithReflectionInjectionTest {
    
        @Test
        public void testOverridePrivateModifierOfField() throws Exception {
            // Arrange
            StubExtensionManager fake = new StubExtensionManager();
            fake.shouldExtensionsBeValid = true;
            FileParserWithReflectionInjection log = new FileParserWithReflectionInjection();
            this.injectToField(log, "extensionManager", fake);
    
            // Act
            boolean actualResult = log.isValidLogFileName("validLogFile.ext");
    
            // Assert
            assertTrue(actualResult);
        }
    
        private void injectToField(Object target, String fieldName, Object dependency) {
            try {
                Field field = target.getClass().getDeclaredField(fieldName);
    
                if (!field.isAccessible()) {
                    field.setAccessible(true);
                }
    
                field.set(target, dependency);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
    

    從上面的測試程式碼來看,新增了一個injectToField方法,這個方法有三個參數,分別為測試物件、測試物件的屬性名稱,以及依賴物件,再來可以透過getClass().getDeclaredField(“屬性名稱"),取得測試物件的Field,這時就能去修改它的權限,把private設定成public,再透過field.set()方法則能把依賴物件注入了,最後我們也是成功完成測試了,如下圖。


    小結

    我們對於測試替身以及依賴注入的介紹夠多了,為何使用測試替身和依賴注入?原因在於需要隔離測試目標,這樣才能模擬出所有情境,並且測試到應有的行為。但目前我們介紹測試替身都是使用手動建置,手動建置有它的成本,但為何要先介紹手動建置?個人認為應該先理解原理,再去尋求更快捷的方法,所以接下來我們就可以利用其他便利的框架,為我們的單元測試提供更多的服務囉!

    Source Code

    Github: https://github.com/xavier0507/UnitTestSample.git

    2016年12月6日 星期二

    Testing: 再論測試替身

    前言

    在Testing: 解除依賴!測試替身與依賴注入一文中,認識了測試替身與依賴注入,並且知道這兩者的目的為何,但都只知其一,接下來要深入瞭解其他方式與用法。

    Fake(偽造物件)讓你的程式順利測試,又不會有副作用。

    下面這張是關於測試替身的類型,在前一篇內容有出現過,但只提到了Stub這種測試替身,那其他像是Fake、Spy和Mock又是甚麼?首先,先來提Fake!


    這種測試替身對我們來說一點也不陌生,開發者即使沒有寫單元測試時,也會用一些假資料,這種假資料有點類似真實資料的簡單版本,這就是Fake,它既不影響到你的程式,也能模擬幾近真實的場景。假設我們有個UserRepository的介面,這個UserRepository在真實情況是透過資料庫連結後,尋找User物件資料,類別圖與程式碼如下。

    • User物件
    public class User {
        private int id;
        private String userName;
    
        public int getId() {
            return id;
        }
    
        public void setId(int id) {
            this.id = id;
        }
    
        public String getUserName() {
            return userName;
        }
    
        public void setUserName(String userName) {
            this.userName = userName;
        }
    }
    
    • UserRepository介面
    public interface UserRepository {
        void save(User user);
    
        User findById(int id);
    
        User findByUserName(String userName);
    }
    • DatabaseUserRepository物件
    public class DatabaseUserRepository implements UserRepository {
    
        @Override
        public void save(User user) {
            // 連接資料庫,並且將User存入資料庫中。
        }
    
        @Override
        public User findById(int id) {
            // 如果連接資料庫成功,則從資料庫透過Id尋找該User,找不到則回傳空物件。
    
            return null;
        }
    
        @Override
        public User findByUserName(String userName) {
            // 如果連接資料庫成功,則從資料庫透過userName尋找該User,找不到則回傳空物件。
    
            return null;
        }
    }
    • Client物件
    public class Client {
        private UserRepository userRepository;
    
        public Client(UserRepository userRepository) {
            this.userRepository = userRepository;
        }
    
        public void saveUser(User user) {
            // 其他商業邏輯
    
            this.userRepository.save(user);
        }
    
        public User findById(int id) {
            // 其他商業邏輯
    
            return this.userRepository.findById(id);
        }
    
        public User findByUserName(String userName) {
            // 其他商業邏輯
    
            return this.userRepository.findByUserName(userName);
        }
    }
    

    User物件就是單純的資料物件;UserRepository介面提供資料的操作,無論你是使用資料庫、網路或檔案系統;DatabaseUserRepository物件則是UserRepository的實作,這裡採用資料庫存取,但細節不實作,僅用註解示意;Clien物件就是一個操作UserRepository的使用端物件,考量現實環境,這邊用註解模擬其他商業邏輯。

    從類別圖我們可以看見,用戶端要是真的測試這個場景,就必須用到真實的外部資源,但是依照單元測試的特性,我們就是無法依賴外部資源也要進行測試啊!這時就可以使用Fake物件來模擬這種情況,同時也能進行測試,類別圖與程式碼如下。

    • FakeUserRepository物件
    public class FakeUserRepository implements UserRepository {
        private Collection<User> users = new ArrayList<>();
    
        @Override
        public void save(User user) {
            if (this.findById(user.getId()) == null) {
                this.users.add(user);
            }
        }
    
        @Override
        public User findById(int id) {
            for (User user : users) {
                if (user.getId() == id) {
                    return user;
                }
            }
    
            return null;
        }
    
        @Override
        public User findByUserName(String userName) {
            for (User user : users) {
                if (user.getUserName().equals(userName)) {
                    return user;
                }
            }
    
            return null;
        }
    }
    • UserRepositoryTest測試類別
    public class UserRepositoryTest {
        private Client client;
        private FakeUserRepository fake;
    
        @Before
        public void setUp() throws Exception {
            this.fake = new FakeUserRepository();
            this.client = new Client(this.fake);
        }
    
        @Test
        public void testFindByIdIsActuallyFound() throws Exception {
            // Arrange
            this.addFakeUsers();
    
            // Act
            User actualResult = this.client.findById(1);
    
            // Assert
            assertThat(actualResult.getId(), equalTo(1));
        }
    
        @Test
        public void testFindByIdIsNotFound() throws Exception {
            // Arrange
            this.addFakeUsers();
    
            // Act
            User actualResult = this.client.findById(0);
    
            // Assert
            assertThat(actualResult, nullValue());
        }
    
        @Test
        public void testFindByUserNameIsActuallyFound() throws Exception {
            // Arrange
            this.addFakeUsers();
    
            // Act
            User actualResult = this.client.findByUserName("Xavier3");
    
            // Assert
            assertThat(actualResult.getUserName(), equalTo("Xavier3"));
        }
    
        @Test
        public void testFindByUserNameIsNotFound() throws Exception {
            // Arrange
            this.addFakeUsers();
    
            // Act
            User actualResult = this.client.findByUserName("Xavier0");
    
            // Assert
            assertThat(actualResult, nullValue());
        }
    
        private void addFakeUsers() {
            for (int i = 0; i < 5; i++) {
                User user = new User();
                user.setId(i + 1);
                user.setUserName("Xavier" + (i + 1));
    
                this.fake.save(user);
            }
        }
    }
    

    這個FakeUserRepository就是所謂的Fake物件,它模擬了存取資料庫的情況。最後我們成功通過測試啦!如下圖。這時你就可以在你的場景中用它來替換掉緩慢的現實場景,以及望塵莫及的依賴性。


    Spy(測試間諜),來偷取秘密吧!

    接著來談Spy!先前有提過,對於單元測試的結果斷定,會涉及三種形式,其中一種就是預期被測物件與相依物件的互動,這種往往沒有回傳值可以讓你斷言,舉個例子,以下有兩個方法。
    • 有回傳值
    public String concat(String first, String second) {
         ............................
    }
    

    對於有回傳值的檢驗方式,直覺就是將兩個String參數傳進去,並且斷言回傳值是什麼就好。這沒什麼問題,畢竟回傳值的確是我們最關心的,那下列的方法該如何測試?
    • 沒有回傳值
    public void recordMessages(Level level, String message) {
            for (ILogger each : loggers) {
                each.write(level, message);
            }
    }
    

    這裡並沒有回傳值供我們斷言,這個方法做的就是一個Logger集合的訊息寫入,要驗證這個方法是否正常工作,唯一的方式就是事後我們檢查列表。就像是你派臥底警察,然後他回報給你所知道的一切。有時這一點無需使用測試替身,因為參數本身就提供測試足夠的訊息,可以讓你獲取額外資訊,但在某些情境中,參數可能資訊不足,我們可以看以下例子。
    • ILogger介面
    public interface ILogger {
        void write(Level level, String message);
    }
    • LoggerSystem物件
    public class LogSystem {
        private final ILogger[] loggers;
    
        public LogSystem(ILogger... loggers) {
            this.loggers = loggers;
        }
    
        public void recordMessages(Level level, String message) {
            for (ILogger each : loggers) {
                each.write(level, message);
            }
        }
    }
    

    以上的程式碼可以知道,被測的物件是一個LogSystem物件,裡面有著一個記錄不同Logger的陣列。當我們向LogSystem執行recordMessages方法時,應該向所有的Logger物件寫入同樣的訊息。在測試的觀點來看,我們無法知道指定的訊息是否被寫入,然而Logger物件也只有提供一個write方法,資訊顯然也不足讓測試者獲取,這時可以悄悄地讓Spy登場了。
    • SpyLogger物件
    public class SpyLogger implements ILogger {
        private List<String> logMessages = new ArrayList<>();
    
        @Override
        public void write(Level level, String message) {
            this.logMessages.add(this.concatenated(level, message));
        }
    
        public boolean received(Level level, String message) {
            return this.logMessages.contains(concatenated(level, message));
        }
    
        private String concatenated(Level level, String message) {
            return level.getName() + ": " + message;
        }
    }
    • LogSystem測試物件
    public class LogSystemTest {
    
        @Test
        public void testWriteEachMessageToAllTargets() throws Exception {
            // Arrange
            SpyLogger spy1 = new SpyLogger();
            SpyLogger spy2 = new SpyLogger();
            SpyLogger spy3 = new SpyLogger();
            SpyLogger spy4 = new SpyLogger();
            SpyLogger spy5 = new SpyLogger();
            LogSystem logTarget = new LogSystem(spy1, spy2, spy3, spy4, spy5);
    
            // Act
            logTarget.recordMessages(Level.INFO, "這些是訊息!");
    
            // Assert
            assertThat(spy1.received(Level.INFO, "這些是訊息!"), is(true));
            assertThat(spy2.received(Level.INFO, "這些是訊息!"), is(true));
            assertThat(spy3.received(Level.INFO, "這些是訊息!"), is(true));
            assertThat(spy4.received(Level.INFO, "這些是訊息!"), is(true));
            assertThat(spy5.received(Level.INFO, "這些是訊息!"), is(true));
        }
    }
    

    看到了嗎?這就是測試間諜,如同其他測試替身,你把派出這些間諜,當作參數傳入,然後你讓測試間諜擴充功能紀錄已發送的訊息,並且提供測試鞭打拷問它的方法,是否收到指定的訊息。最後,我們也成功通過測試了!如下圖。


    Mock(模擬物件),間諜老大就是你!

    其實Mock就是一種全能的測試替身,可以算是Stub與Spy的結合,你既可以指定它所回傳的預設值,同時也可以像Spy那樣擴充它的功能去記錄過去所發生的事情,好提供足夠的資訊讓測試程式知道,如下列程式。
    • ICheckInFee介面
    public interface ICheckInFee {
        int getFee(Customer customer);
    
        double getDiscountedFee(Customer customer);
    }
    • Customer資料物件
    public class Customer {
        private boolean isVIP;
    
        public boolean isVIP() {
            return isVIP;
        }
    
        public void setVIP(boolean VIP) {
            isVIP = VIP;
        }
    }
    • BookStore操作物件
    public class BookStore {
        private ICheckInFee checkInFee;
        private double income = 0;
    
        public BookStore(ICheckInFee checkInFee) {
            this.checkInFee = checkInFee;
        }
    
        public void checkInFee(List<Customer> customers) {
            for (Customer customer : customers) {
                boolean isVIP = customer.isVIP();
    
                if (isVIP) {
                    this.income += this.checkInFee.getDiscountedFee(customer);
                } else {
                    this.income += this.checkInFee.getFee(customer);
                }
            }
        }
    
        public double getIncome() {
            return income;
        }
    }
    

    以上是個簡單的書店物件,用來計算VIP和一般顧客的費用,以這個程式的測試觀點,checkInFee方法是不返回任何值的,唯一能獲取income資料的是getIncome方法,然而要測試checkInFee方法是否功能正常,我們則要檢驗BookStore與ICheckInFee間的互動,並且在是否為VIP時,各自呼叫哪個方法,程式碼如下。
    • MockCheckInFee物件
    public class MockCheckInFee implements ICheckInFee {
        public int getFee_called_counter = 0;
        public int getDiscountedFee_called_counter = 0;
    
        @Override
        public int getFee(Customer customer) {
            this.getFee_called_counter++;
            return 1000;
        }
    
        @Override
        public double getDiscountedFee(Customer customer) {
            this.getDiscountedFee_called_counter++;
            return 1000 * 0.8;
        }
    }
    • BookStoreTest測試物件
    public class BookStoreTest {
    
        @Test
        public void testCheckInFeeNotVIP() throws Exception {
            // Arrange
            MockCheckInFee mockCheckInFee = new MockCheckInFee();
            BookStore bookStore = new BookStore(mockCheckInFee);
    
            // Act
            bookStore.checkInFee(this.getCustomers());
    
            // Assert
            assertThat(mockCheckInFee.getFee_called_counter, equalTo(2));
            assertThat(mockCheckInFee.getDiscountedFee_called_counter, equalTo(4));
        }
    
        @Test
        public void testGetIncome() throws Exception {
            // Arrange
            MockCheckInFee mockCheckInFee = new MockCheckInFee();
            BookStore bookStore = new BookStore(mockCheckInFee);
    
            // Act
            bookStore.checkInFee(this.getCustomers());
    
            // Assert
            assertThat(bookStore.getIncome(), equalTo(5200.0));
        }
    
        private List<Customer> getCustomers() {
            List<Customer> customers = new ArrayList<>();
            Customer customer1 = new Customer();
            Customer customer2 = new Customer();
            Customer customer3 = new Customer();
            Customer customer4 = new Customer();
            Customer customer5 = new Customer();
            Customer customer6 = new Customer();
            customer1.setVIP(true);
            customer2.setVIP(false);
            customer3.setVIP(false);
            customer4.setVIP(true);
            customer5.setVIP(true);
            customer6.setVIP(true);
            customers.add(customer1);
            customers.add(customer2);
            customers.add(customer3);
            customers.add(customer4);
            customers.add(customer5);
            customers.add(customer6);
    
            return customers;
        }
    }
    

    我們讓MockCheckInFee除了擔任Stub所需要的角色外,我們同時還多了getFee_called_counter與getDiscountedFee_called_counter這兩個成員變數記錄各自方法被呼叫了幾次,提供足夠的資訊讓測試程式了解,這就是Mock強大的功能囉!最後,我們也完成這次的測試,如下圖。


    小結

    至目前為止,我們討論完剩下的測試替身了,它們是開發者的測試工具,也沒有硬性規定只能使用哪一種,甚至混合使用或身兼多職都是可以的。當然,我們還是有些啟發式準則能知道這些測試替身出現的合適場景。
    • 如果你關心的是測試物件與其他物件的交互,而且你著重協作者的方法調用,你就有可能需要一個Mock物件。
    • 如果你的環境很複雜,你又想執行這個複雜的環境,那可以考慮使用Fake物件。
    • 如果你想使用Mock,但又想精簡它,可能可以考慮簡化成Spy物件。
    • 如果你的被測物件只關心合作物件帶給你的值,可以使用Stub物件就好。
    還是覺得太難記?這裡有些好記的方式,Stub/Fake和Spy/Mock物件差異在於,我們不會對前者斷言,卻會對後者斷言;Stub管的是值的查詢,Mock則是管方法操作。持續練習單元測試吧!

    Source Code

    Github: https://github.com/xavier0507/UnitTestSample.git