陳昱儒
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Engagement control Commenting, Suggest edit, Emoji Reply
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights
    • Engagement control
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Versions and GitHub Sync Note Insights Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Engagement control Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       owned this note    owned this note      
    Published Linked with GitHub
    Subscribed
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    Subscribe
    # 初探 UI - TEST - UI Test 有分只有code 跟有畫面渲染的,這次走的是有畫面渲染的, 缺點就是偶爾會因為模擬機的問題而導致測試失敗。 ## 初探程度 1. 其實只有簡單幾個頁面的應用,基本的登入、註冊流程、看頁面是否有依照狀態做出不同的按鈕邏輯這樣。 2. 可以改寫api,像unit test那樣,透過koin注入,直接讓api回傳假資料 3. 這次整個UI TEST是建立在espresso、koin下完成的 ## 自己理解的UI Test 1. UI TEST,他就是會跟正常使用手機一樣,如果再你沒有調整api、或對按紐做例外判斷時,就真的會依照當前APP狀態去判斷做事情,但是他沒有所謂的等api好的概念,你只能自己設定要停多少秒(好像是有可以等的,但很複雜? 難?)。 2. 並且在實際寫過真的打api後,發現有很多問題,反倒會導致測試失敗,EX:網路錯誤、剛好遇到正確吐失敗的結果、要刪資料庫已更新狀態等等... 3. 最後就理解了,UI TEST 可以將目標設定在測畫面上的邏輯即可(要測api也行,但就是失敗率高,要再研究更好的做法,看專案的目的)。 4. 小結: - UI TEST:只專注在畫面上的邏輯,透過假資料驗證在收到各不同資料時,是否會正常運作,顯示對應、需要的資訊。 - UNIT TEST:可以透過UNIT TEST去驗證商業邏輯或是api是否正確。 - 將兩者都寫好,一起跑,就可以相輔相成。 ## 實際操作 接下來的作法,都是建立目標&範圍在上面的情境下,接下來就讓我們開始吧。 ### 引用所需的套件 * 基本上就是引用espresso跟JUnit、Android測試相關的class * 這裡要注意的是 - androidTestImplementation,就是UI TEST才會引用 - testImplementation,就是Unit TEST才會引用 - 因為espresso 有時會跟 robolectric 衝突,如果其實沒再UI TEST用到robolectric,就只需要注意好引用即可 - 另外facebook 15.0.1有不小心把robolectric引用開成implementation,所以會導致衝突,升到15.0.2以上即可 - 阿還會用到koin,就自行引用需要的,或是自己習慣的di工具吧~ - 版本可以自行查詢最新版,或跟專案版本相符的 ``` androidTestImplementation 'androidx.test:core:1.5.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' // AndroidJUnitRunner and JUnit Rules androidTestImplementation 'androidx.test:runner:1.5.2' // deprecated androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test:rules:1.5.0' ``` ## 基本用法 免責聲明:由於我也是初次嘗試,可能有理解錯誤,或是以為不行/可以的行為,結果是錯誤的,如果有發現的話可以跟我說,感恩的心~~~ ### 懶人或是毫無頭緒的時候 - 直接在上方run 選 `Recode Espresso Test`,然後就可以在模擬機上操作流程,都完成後按OK,就可以匯出檔案。 - 因為通常都會有中文,所以會出來的檔案沒辦法直接用,中文部分會是亂碼,要先把他修正,之後才能跑。 - 用這個方式,畫面流程會從一開始的Launcher頁開始,無法直接跳至某個頁面 - 圖文教學請看文章底部[基礎教學】Android TDD 系列 — 12 UI 測試:使用Espresso](https://medium.com/evan-android-note/android-tdd-%E7%B3%BB%E5%88%97-12-ui-%E6%B8%AC%E8%A9%A6-%E4%BD%BF%E7%94%A8espresso-dfa60d2f31a) ### QuickStart-1 這是一個範例,關於一個平凡無奇的操作:找到一個View、做一個點擊操作、檢查一個View有沒有包含什麼文字 ```kotlin= @Test fun sampleTest() { onView(withId(R.id.loginButton)) //找到登入按鈕 .perform(click()) // 做一個點擊操作 onView(withId(R.id.loginStatusTextView)) //找到登入狀態顯示TextView .check(matches(withText(R.string.loginSuccess))) //檢查TextVeiw文字有沒有變成登入成功 } ``` 以上出現了幾個關鍵詞,後面陸續說明: - `onView(匹配條件 - viewMatcher)` - `perform(動作 - viewActions)` - `check(斷言 - viewAssert)` ### 如何找到畫面上的view 1. 當知道Id時:`onView(withId({IdRes}))` - 會直接在當前畫面找這個id,但要注意一旦他沒找到,他就會跑測試失敗了 - **另外要注意找的id,一定要是整個專案唯一值,如果有兩個取名相同的,他會抓錯位置,也會直接爆掉,變成測試失敗** - 怎麼解決呢? 就改名呀XD 2. 如果不知道id,或是他是動態生成的:`onView(withText("String")` 3. 如果只是要選底層:`onView(isRoot())` 4. 其他只要是吻合或是實現`Matcher`的都可以當作尋找view的條件。 ### 如何操作View 1. 先找到你的View,然後給他一個ViewAction的動作即可。 2. 官方有一些default可以用,例如click(點擊)、scroll(滾動)等,可以去[官方文件](https://developer.android.com/reference/androidx/test/espresso/action/ViewActions),查詢。 3. 有你沒有辦法操作的,就自己實現ViewAction即可,改寫資訊。 ```kotlin= fun clearEditTextFocus(): ViewAction { return object : ViewAction { //需要給他這個ViewAction是操作哪個View的 //-如果是原生的元件:ViewMatchers.isAssignableFrom({元件}::class.java)` //-如果是自己刻的元件:allOf(isDisplayed(), isAssignableFrom({自製元件}::class.java))` override fun getConstraints(): Matcher<View> { return ViewMatchers.isAssignableFrom(EditText::class.java) } //只是描述,字串隨便你打,可以判別即可 override fun getDescription(): String { return "Clear focus on the view" } //這裡就可以拿到view,看你要怎麼操作都行,如果是自製View,就先把他轉型再操作即可 override fun perform(uiController: UiController?, view: View?) { view?.clearFocus() } } } ``` ### 如何檢查View的狀態是否如預期 1. 先找到你的View,然後給他一個ViewAssert即可。 2. 可以透過`matches(withText("String"))`,來去驗證view有沒有這個文字。 3. matches裡面是傳入一個ViewMatcher (跟尋找View是一樣的)。 ### QuickStart1小結 基本上整個操作邏輯都在這幾個介面,因此你只需要實現他,就可以做到大部分的你想要的操作: - `ViewMatcher`:不管你想找View,還是想要確認狀態都需要他。 - 找不到是會報Exception,導致測試失敗 - `ViewActions`:針對View的操作 - `ViewAssert`:需要搭配ViewMatcher,驗證畫面狀態如自己的預期 - 不吻合時,是報AssertionFailedError,測試失敗 要注意一個是噴Exception一個是Error,tryCatch要兩個都抓才會抓住。 ## 微進階用法 想必照著上面操作完,應該還是失敗的吧0.0 因為少了UI-TEST最重要的delay,因為模擬機、首頁讀取、甚至是api?等因素,通常去找畫面或是操作,都會有一定的延遲。 然而程式碼執行時可沒那麼客氣,跑到那邊沒有找到id或是check不對就給你報錯,因此只好告訴他,給我稍等一下。 ### 我要怎麼Delay? 1. 關鍵Class`CountingIdlingResource` ,其實他的底層邏輯就是一個開關,我們可以設定打開+1,到0的時候他才繼續做事。 2. 使用他需要再測試的Before跟After 註冊&解註冊。 ```kotlin= companion object { private val countingIdlingRes = CountingIdlingResource("隨意取名") } @Before fun setUp() { // 註冊 CountingIdlingResource IdlingRegistry.getInstance().register(countingIdlingRes) } @After fun tearDown() { // 取消註冊 CountingIdlingResource IdlingRegistry.getInstance().unregister(countingIdlingRes) } ``` 3. 然後再使用他時,就在需要等候的地方,操作實體的方法即可。 ```kotlin= fun test(){ onView(withId(R.id.loginButton)).perform(click()) //在按鈕點擊後,等個一秒,讓畫面確定有改變 countingIdlingResource.delay() //一秒後會繼續進行原本要做的事情 onView(withId(R.id.loginStatusTextView)).check(matches(withText(R.string.loginSuccess))) } /** * 延遲執行,不管要等畫面渲染或api Callback都要設delay */ fun CountingIdlingResource.delay(millis: Long = 1000) { //開始暫停 increment() //強制等幾秒 Thread.slep(millis) //解除暫停 decrement() } ``` 4. 目前尚未研究出要如何精準的抓到Callback,記得在爬文研究時,好像是有方法,但極其複雜就是了,但理論上沒打Api,應該比較不會用到需要精準的callback時間。 5. 使用時記得要考慮到模擬機有時候真的很慢,因此有些時候寧可多delay個0.幾秒,讓他畫面渲染都跑完。 ### 其他常用的方法 #### 驗證Intent - 確定是否在這個Activity 1. 要使用intent,記得一樣要在Before跟After上做處理 ```kotlin= @Before fun setUp() { // 初始化 Intents 已在後續捕捉Intent Intents.init() } @After fun tearDown() { // 釋放 Intents Intents.release() } ``` 2. 之後再需要檢查的地方用以下方式即可驗證現在是否是在這個Activity中 `Intents.intended(IntentMatchers.hasComponent({Activity名稱}::class.java.name))` #### Assert.assertTrue UI TEST跟 Unit Test 一樣可以用Assert去做斷言。EX: `Assert.assertTrue(checkViewIsNotExist(R.id.loginStatusTextView))` 用來判斷登入狀態的View消失了沒。 #### 如何選擇RecycleView的View ```kotlin= val recyclerView = onView(withId(R.id.loginRecycleView)) //直接告知要點RecyclerView的第幾個 recyclerView.perform(actionOnItemAtPosition<RecyclerView.ViewHolder>({Int(position)}, click())) ``` ## 進階用法 上面基本上已經可以讓你的測試成功開啟並運行了,再來就是讓測試更貼切你的實務面,畢竟大家應該都是已經有的畫面,會有api,每次就算設delay等api回來,偶爾還是會遇到網路太慢而導致測試失敗,因此是時候該捨棄掉api了。 並且有沒有覺得每次都從Launcher頁開始,好麻煩阿~ ### 該如何直接打開指定的頁面開始測試呢? 1. 使用ActivityScenarioRule:直接在測試Class中定義起始的Activity - 只會生效第一個,因此不能用兩個Rule - 這個方法,你無法在extras放參數,也就是說你的Activity如果有必要的參數要帶,不能用這個 ```kotlin= /** * 定義測試要從哪個Activity開始 */ @Rule @JvmField var startActivityScenarioRule = ActivityScenarioRule(LoginActivity::class.java) ``` 2. 使用ActivityScenario: - 這個方法,是你自己創intent,然後在透過`ActivityScenario.launch<Class>(Intent)`跳轉,因為是自己建Intent,所以可以帶參數進去extras - 在Test結束時要手動把scenario關閉 ```kotlin= @Test fun SampleTest2() { //直接創建目標Activity的Intent val intent = Intent(ApplicationProvider.getApplicationContext(), TargetActivity::class.java).apply { putExtra("Need_ID", "123456") } val scenario: ActivityScenario<TargetActivity> = ActivityScenario.launch<TargetActivity>(intent) /* * 在這中間做你想做的事情 */ //結束後要把這個scenario關閉 scenario.close() } ``` ### 如何替換掉API,變成自己的假資料 方法可能有滿多種的,本次採用的方法是透過koin,注入假的Domain,去回傳假資料,然後還有用了一些有點硬改,但目前也沒好的IDEA的做法。 #### 先決條件 請先確保你用到的畫面,ViewModel、Domain層(Repository或UseCase)都有用koin定義好了module,然後domain層有用Interface做處理,需要的架構範例如下方(看自己專案定義到哪邊,哪邊需要抽換)。 沒有的話,請先調整專案再進行,因為我們會需要再測試時,透過注入自己時間的Interface去替換假資料。 ```kotlin= interface TestDomain{ fun getData(): Data } //建在正式環境main Module的Repository class TestRepository: TestDomain{ override fun getData(): Data{ //正式時要做的事情,打api拿資料之類的 } } //建在AndroidTest的Repository class FakeTestRepository: TestDomain{ override fun getData(): Data{ //可以直接Return假資料,或想做的操作 } } ``` #### 最累的一步:建置假環境,把Module替換掉 1. 先說一個觀念,androidTest module可以調用到main module的方法、class,但main是讀不到androidTest的。 2. 基本上main會有一個Application,去讀取koin的設定檔,因此我們現在就是要替換掉這個module,但是我們沒辦法單純替換module檔,除非你要在main的資料夾放上UI TEST的假資料,但風險很高。 3. 因此我們會在androidTest也製作一顆application,並作所有相關的初始化動作,並在這時候把fakeModule,放入startKoin裡面。 - startKoin裡的modules,可以放正式機再用的,跟假資料的,但要注意相同定義的不能重複,EX:我有FakeRepository了,就不能再讀取RepositoryModule,除非裡面定義的東西完全不同,不然會build不起來 - 這裡要記得依照自己專案的架構、假資料需替換的地方,以範例舉例,是我只需要替換Repository層的資料即可。 ```kotlin= //大概module長這樣 get代表拿一個Repository val ViewModelModule = module { viewModel { LoginViewModel(get()) } viewModel { TargetViewModel() } } val RepositoryModule = module{ //這個get()是拿RemoteDataSource single<TestDomain> { TestRepository(get()) } } val FakeRepositoryModule = module{ //這個get()是拿TestRepository single<TestDomain> { FakeTestRepository(get()) } //如果你需要delegate的話,請定義 //這個get()是拿RemoteDataSource single<TestRepository> { TestRepository(get()) } } startKoin { androidContext(this) modules( listOf( ViewModelModule, // RepositoryModule, FakeRepositoryModule, //在FakeApplication,把Repository層替換層假資料層 RemoteDataSourceModule, LocalDataModule ) ) } ``` 4. 再來就是排錯了,通常整個Application複製一份到測試,會滿多錯誤的,要先把一些不會用到的方法註解掉,而如果有用到上帝物件,直接指定MainApplication的物件,可以透過寫Fake的方式調整他。(不是好方法,但可以解)EX: ```kotlin= //在MainApplication companion object{ //UI-Test Use var fakeContext: Context? = null private var instance: Application? = null //先拿Main的Context,如果沒拿到代表在測試環境,就拿AndroidTest的Context fun applicationContext(): Context { return instance?.applicationContext ?: fakeContext!! } } //在AndroidTest的Application,將假的data設定上去 override fun onCreate() { //... MainApplication.fakeContext = this.applicationContext } ``` #### 再來就可以開心創建假資料了 將上面需要替換假資料的,Fake資料建一建,然後確定module都有讀到即可。 #### 可是...我可能只需要其中一個假資料,其他都用不到,但我要改寫整個class也太累了吧 小撇步,透過delegate輕鬆搞定,透過直接注入已經好的TestRepository,再用委託的方式直接把該實現的方法由他去實現,這樣就OK了 ```kotlin= class FakeTestRepository(private val testRepository: TestRepository) : TestDomain by testRepository { //只需要override你需要改變的方法即可 } ``` ### 最後一些常見的實用方法 ```kotlin= /** * 有些是可有可無的畫面,可以包try catch,沒有出現就跳過,不影響流程 */ fun canSkipClick(action: () -> Unit) { try { action() } catch (e: Exception) { e.printStackTrace() } catch (e: AssertionFailedError) { e.printStackTrace() } } /** * 直接用try...catch 包檢查ViewId有沒有存在,來判斷畫面是否存在。 * 適用情境: * 1.需要看哪個View出現,做哪件事情時 * 2.要拿來判斷是否離開某個畫面(因為他如果已經離開,那withId那邊就會爆掉找不到view...) */ fun checkViewIsNotExist(viewId: Int): Boolean { return try { onView(withId(viewId)).check(doesNotExist()) true } catch (e: Exception) { //找不到View的話,也算是View 不存在 -> 如果超過10秒沒找到 true } catch (error: AssertionFailedError) { //如果check驗證錯誤,代表view存在 false } } ``` ## 參考資料 [- 官方文件Espresso](https://developer.android.com/training/testing/espresso/recipes?hl=zh-cn) [- 官方SampleCode](https://github.com/android/testing-samples) ## 延伸閱讀(還沒研究der) [- D27 / 怎麼測試? - Testing Compose](https://ithelp.ithome.com.tw/articles/10280628?sc=iThomeR) [- Android 測試從零到英雄教程 - 第 3 部分](https://medium.com/geekculture/testing-in-android-a-zero-to-hero-tutorial-part-3-b1a3b5504965) [- 官方文件自動化UI測試](https://developer.android.com/training/testing/instrumented-tests/ui-tests)

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password

    or

    By clicking below, you agree to our terms of service.

    Sign in via Facebook Sign in via Twitter Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully